primocode 8.25.0 → 8.26.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/app/app.css +230 -0
- package/app/app.js +273 -0
- package/app/icone.svg +6 -0
- package/app/index.html +68 -0
- package/bin/primocode.js +52 -0
- package/lib/app.js +334 -0
- package/package.json +3 -2
package/app/app.css
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/* A janela do PrimoCode.
|
|
2
|
+
*
|
|
3
|
+
* "Um aplicativo desktop bem bonito com as cores do PrimoCode, com aquele
|
|
4
|
+
* azul. Bem estilo Codex. Um chat estilo terminal para quem não quer o
|
|
5
|
+
* terminal."
|
|
6
|
+
*
|
|
7
|
+
* As cores não foram escolhidas aqui: são as mesmas de lib/ui.js, que já vêm
|
|
8
|
+
* do design system do produto. cyan-400 é o azul do PrimoCode; slate é a
|
|
9
|
+
* neutra; amber avisa e rose reprova. Repetir o terminal na tela não é falta
|
|
10
|
+
* de imaginação — é o que faz as duas telas serem o MESMO produto.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
--azul: #22d3ee; /* cyan-400 — a marca */
|
|
15
|
+
--azul-fundo: #0891b2; /* cyan-600 — a mesma cor recuando */
|
|
16
|
+
--texto: #e2e8f0; /* slate-200 */
|
|
17
|
+
--fraco: #94a3b8; /* slate-400 */
|
|
18
|
+
--apagado: #64748b; /* slate-500 */
|
|
19
|
+
--alerta: #fbbf24; /* amber-400 */
|
|
20
|
+
--falha: #fb7185; /* rose-400 */
|
|
21
|
+
|
|
22
|
+
--fundo: #0a0f16;
|
|
23
|
+
--painel: #0f1620;
|
|
24
|
+
--borda: #1e293b;
|
|
25
|
+
--borda-viva: #24384f;
|
|
26
|
+
|
|
27
|
+
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo,
|
|
28
|
+
Consolas, "Liberation Mono", monospace;
|
|
29
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
* { box-sizing: border-box; }
|
|
33
|
+
|
|
34
|
+
/* `display:flex` e `display:grid` nas regras abaixo ganham do `[hidden]` do
|
|
35
|
+
navegador, que é só `display:none` sem peso nenhum. O resultado era o botão
|
|
36
|
+
de parar e o "trabalhando" aparecendo com o agente parado — a janela dizia
|
|
37
|
+
que estava fazendo alguma coisa o tempo todo. */
|
|
38
|
+
[hidden] { display: none !important; }
|
|
39
|
+
|
|
40
|
+
html, body {
|
|
41
|
+
height: 100%;
|
|
42
|
+
margin: 0;
|
|
43
|
+
background: var(--fundo);
|
|
44
|
+
color: var(--texto);
|
|
45
|
+
font-family: var(--sans);
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
body {
|
|
51
|
+
display: grid;
|
|
52
|
+
grid-template-rows: auto 1fr auto;
|
|
53
|
+
/* O brilho azul atrás de tudo. Um só, bem aberto e bem fraco: dá
|
|
54
|
+
profundidade sem virar papel de parede. */
|
|
55
|
+
background-image:
|
|
56
|
+
radial-gradient(120% 80% at 50% -20%, rgba(34, 211, 238, .08), transparent 62%);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ── topo ───────────────────────────────────────────────────────────────── */
|
|
60
|
+
|
|
61
|
+
#topo {
|
|
62
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
63
|
+
gap: 16px; padding: 11px 18px;
|
|
64
|
+
border-bottom: 1px solid var(--borda);
|
|
65
|
+
background: rgba(10, 15, 22, .72);
|
|
66
|
+
backdrop-filter: blur(14px);
|
|
67
|
+
/* Na janela do Chrome em modo app não há barra de título: esta faixa é a
|
|
68
|
+
barra, e precisa poder arrastar a janela. */
|
|
69
|
+
-webkit-app-region: drag;
|
|
70
|
+
user-select: none;
|
|
71
|
+
}
|
|
72
|
+
#topo button, #topo .chip { -webkit-app-region: no-drag; }
|
|
73
|
+
|
|
74
|
+
.marca { display: flex; align-items: center; gap: 9px; }
|
|
75
|
+
.logo { width: 19px; height: 19px; color: var(--azul); flex: 0 0 auto; }
|
|
76
|
+
.nome { font-weight: 640; letter-spacing: -.012em; }
|
|
77
|
+
.versao { color: var(--apagado); font-size: 11.5px; font-family: var(--mono); }
|
|
78
|
+
|
|
79
|
+
.estado { display: flex; gap: 7px; align-items: center; min-width: 0; }
|
|
80
|
+
.chip {
|
|
81
|
+
font-family: var(--mono); font-size: 11.5px;
|
|
82
|
+
color: var(--fraco); background: var(--painel);
|
|
83
|
+
border: 1px solid var(--borda); border-radius: 999px;
|
|
84
|
+
padding: 3px 10px; white-space: nowrap;
|
|
85
|
+
max-width: 300px; overflow: hidden; text-overflow: ellipsis;
|
|
86
|
+
}
|
|
87
|
+
.chip:empty { display: none; }
|
|
88
|
+
#modo { color: var(--azul); border-color: var(--borda-viva); }
|
|
89
|
+
|
|
90
|
+
/* ── conversa ───────────────────────────────────────────────────────────── */
|
|
91
|
+
|
|
92
|
+
#rolo { overflow-y: auto; scroll-behavior: smooth; }
|
|
93
|
+
#rolo::-webkit-scrollbar { width: 10px; }
|
|
94
|
+
#rolo::-webkit-scrollbar-thumb { background: #1c2836; border-radius: 8px; border: 3px solid var(--fundo); }
|
|
95
|
+
|
|
96
|
+
#conversa { max-width: 860px; margin: 0 auto; padding: 26px 22px 40px; }
|
|
97
|
+
|
|
98
|
+
.boas-vindas { padding: 12vh 0 0; text-align: center; }
|
|
99
|
+
.boas-vindas h1 {
|
|
100
|
+
font-size: 27px; font-weight: 620; letter-spacing: -.025em; margin: 0 0 10px;
|
|
101
|
+
}
|
|
102
|
+
.boas-vindas p {
|
|
103
|
+
color: var(--fraco); max-width: 460px; margin: 0 auto 26px; line-height: 1.6;
|
|
104
|
+
}
|
|
105
|
+
.atalhos { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
|
106
|
+
.atalhos button {
|
|
107
|
+
font: inherit; font-size: 13px; color: var(--fraco); cursor: pointer;
|
|
108
|
+
background: var(--painel); border: 1px solid var(--borda);
|
|
109
|
+
border-radius: 999px; padding: 7px 15px;
|
|
110
|
+
transition: border-color .16s, color .16s, transform .16s;
|
|
111
|
+
}
|
|
112
|
+
.atalhos button:hover {
|
|
113
|
+
color: var(--texto); border-color: var(--borda-viva); transform: translateY(-1px);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* O que o usuário disse: bolha à direita, curta, com a marca de lado. */
|
|
117
|
+
.voce {
|
|
118
|
+
margin: 24px 0 14px; display: flex; justify-content: flex-end;
|
|
119
|
+
}
|
|
120
|
+
.voce > div {
|
|
121
|
+
background: linear-gradient(180deg, rgba(34,211,238,.12), rgba(34,211,238,.07));
|
|
122
|
+
border: 1px solid rgba(34, 211, 238, .28);
|
|
123
|
+
border-radius: 14px 14px 4px 14px;
|
|
124
|
+
padding: 10px 15px; max-width: 78%;
|
|
125
|
+
white-space: pre-wrap; word-break: break-word; line-height: 1.55;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* A saída do agente: monoespaçada, como no terminal, porque É a saída do
|
|
129
|
+
terminal — as mesmas linhas, as mesmas cores. */
|
|
130
|
+
.saida {
|
|
131
|
+
font-family: var(--mono); font-size: 12.8px; line-height: 1.62;
|
|
132
|
+
white-space: pre-wrap; word-break: break-word;
|
|
133
|
+
color: var(--texto); margin: 0;
|
|
134
|
+
}
|
|
135
|
+
.saida a { color: var(--azul); }
|
|
136
|
+
|
|
137
|
+
/* As cores ANSI, traduzidas. Os números são os do lib/ui.js. */
|
|
138
|
+
.c45 { color: var(--azul); }
|
|
139
|
+
.c31 { color: var(--azul-fundo); }
|
|
140
|
+
.c254 { color: var(--texto); }
|
|
141
|
+
.c247 { color: var(--fraco); }
|
|
142
|
+
.c243 { color: var(--apagado); }
|
|
143
|
+
.c214 { color: var(--alerta); }
|
|
144
|
+
.c204 { color: var(--falha); }
|
|
145
|
+
.forte { font-weight: 650; }
|
|
146
|
+
|
|
147
|
+
.aviso-erro {
|
|
148
|
+
margin: 14px 0; padding: 10px 14px; line-height: 1.55;
|
|
149
|
+
color: var(--falha); background: rgba(251, 113, 133, .08);
|
|
150
|
+
border: 1px solid rgba(251, 113, 133, .26); border-radius: 10px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ── barra de baixo ─────────────────────────────────────────────────────── */
|
|
154
|
+
|
|
155
|
+
#baixo {
|
|
156
|
+
border-top: 1px solid var(--borda);
|
|
157
|
+
background: rgba(10, 15, 22, .82);
|
|
158
|
+
backdrop-filter: blur(14px);
|
|
159
|
+
padding: 12px 22px 14px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#pensando {
|
|
163
|
+
max-width: 860px; margin: 0 auto 9px;
|
|
164
|
+
display: flex; align-items: center; gap: 9px;
|
|
165
|
+
color: var(--azul); font-family: var(--mono); font-size: 12.5px;
|
|
166
|
+
}
|
|
167
|
+
.pulso {
|
|
168
|
+
width: 7px; height: 7px; border-radius: 50%; background: var(--azul);
|
|
169
|
+
animation: bater 1.15s ease-in-out infinite;
|
|
170
|
+
}
|
|
171
|
+
@keyframes bater {
|
|
172
|
+
0%, 100% { opacity: .28; transform: scale(.72); }
|
|
173
|
+
50% { opacity: 1; transform: scale(1); }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#forma {
|
|
177
|
+
max-width: 860px; margin: 0 auto;
|
|
178
|
+
display: flex; align-items: flex-end; gap: 9px;
|
|
179
|
+
background: var(--painel);
|
|
180
|
+
border: 1px solid var(--borda);
|
|
181
|
+
border-radius: 15px; padding: 8px 8px 8px 15px;
|
|
182
|
+
transition: border-color .16s, box-shadow .16s;
|
|
183
|
+
}
|
|
184
|
+
#forma:focus-within {
|
|
185
|
+
border-color: rgba(34, 211, 238, .45);
|
|
186
|
+
box-shadow: 0 0 0 3px rgba(34, 211, 238, .09);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#campo {
|
|
190
|
+
flex: 1; font: inherit; font-size: 14.5px; line-height: 1.55;
|
|
191
|
+
color: var(--texto); background: none; border: 0; outline: 0;
|
|
192
|
+
resize: none; max-height: 44vh; padding: 6px 0;
|
|
193
|
+
}
|
|
194
|
+
#campo::placeholder { color: var(--apagado); }
|
|
195
|
+
|
|
196
|
+
#forma button {
|
|
197
|
+
flex: 0 0 auto; width: 34px; height: 34px; border-radius: 10px;
|
|
198
|
+
display: grid; place-items: center; cursor: pointer; border: 0;
|
|
199
|
+
transition: background .16s, color .16s, opacity .16s;
|
|
200
|
+
}
|
|
201
|
+
#forma button svg { width: 18px; height: 18px; }
|
|
202
|
+
#mandar { background: var(--azul); color: #04252c; }
|
|
203
|
+
#mandar:hover { background: #67e8f9; }
|
|
204
|
+
#mandar:disabled { background: #16323d; color: #3f6472; cursor: default; }
|
|
205
|
+
#parar { background: rgba(251, 113, 133, .14); color: var(--falha); }
|
|
206
|
+
#parar:hover { background: rgba(251, 113, 133, .24); }
|
|
207
|
+
|
|
208
|
+
.dica {
|
|
209
|
+
max-width: 860px; margin: 8px auto 0;
|
|
210
|
+
color: var(--apagado); font-size: 11.5px; text-align: center;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ── arrastar arquivo ───────────────────────────────────────────────────── */
|
|
214
|
+
|
|
215
|
+
#alvo-solto {
|
|
216
|
+
position: fixed; inset: 0; display: none; place-items: center;
|
|
217
|
+
background: rgba(6, 10, 16, .82); backdrop-filter: blur(3px); z-index: 40;
|
|
218
|
+
}
|
|
219
|
+
body.soltando #alvo-solto { display: grid; }
|
|
220
|
+
#alvo-solto > div {
|
|
221
|
+
border: 2px dashed rgba(34, 211, 238, .55); border-radius: 18px;
|
|
222
|
+
padding: 44px 62px; text-align: center; color: var(--azul);
|
|
223
|
+
display: grid; gap: 6px;
|
|
224
|
+
}
|
|
225
|
+
#alvo-solto span { color: var(--fraco); font-size: 13px; }
|
|
226
|
+
|
|
227
|
+
@media (max-width: 620px) {
|
|
228
|
+
.estado #pasta { display: none; }
|
|
229
|
+
#conversa, #forma, #pensando, .dica { max-width: none; }
|
|
230
|
+
}
|
package/app/app.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/* A janela conversando com o PrimoCode.
|
|
2
|
+
*
|
|
3
|
+
* Nada de framework: é a mesma regra do resto do projeto, e aqui ela nem
|
|
4
|
+
* custa — a página tem um campo, uma lista e um fluxo de eventos.
|
|
5
|
+
*
|
|
6
|
+
* O trabalho de verdade é traduzir a saída do terminal. O agente escreve com
|
|
7
|
+
* códigos ANSI, e jogar isso na tela como texto puro mostraria "[38;5;45m" no
|
|
8
|
+
* meio das frases. Então os códigos viram <span> com a cor certa — as mesmas
|
|
9
|
+
* cores do lib/ui.js, para as duas telas serem o mesmo produto.
|
|
10
|
+
*/
|
|
11
|
+
(function () {
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
/* A chave chega na URL, na primeira abertura. Ela sai de lá assim que é
|
|
15
|
+
lida — numa aba comum ficaria à mostra e no histórico do navegador — mas
|
|
16
|
+
antes vai para o sessionStorage: sem isso, apertar F5 matava a janela,
|
|
17
|
+
porque a URL limpa não tem mais chave nenhuma. */
|
|
18
|
+
var CHAVE = new URLSearchParams(location.search).get("k") || "";
|
|
19
|
+
try {
|
|
20
|
+
if (CHAVE) sessionStorage.setItem("primo-chave", CHAVE);
|
|
21
|
+
else CHAVE = sessionStorage.getItem("primo-chave") || "";
|
|
22
|
+
} catch (e) { /* sem sessionStorage: vale só esta abertura */ }
|
|
23
|
+
if (location.search) history.replaceState(null, "", location.pathname);
|
|
24
|
+
|
|
25
|
+
var conversa = document.getElementById("conversa");
|
|
26
|
+
var rolo = document.getElementById("rolo");
|
|
27
|
+
var campo = document.getElementById("campo");
|
|
28
|
+
var forma = document.getElementById("forma");
|
|
29
|
+
var mandar = document.getElementById("mandar");
|
|
30
|
+
var parar = document.getElementById("parar");
|
|
31
|
+
var pensando = document.getElementById("pensando");
|
|
32
|
+
var ocupado = false;
|
|
33
|
+
|
|
34
|
+
function pedir(rota, corpo) {
|
|
35
|
+
return fetch(rota, {
|
|
36
|
+
method: corpo === undefined ? "GET" : "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json", "X-Primo-Chave": CHAVE },
|
|
38
|
+
body: corpo === undefined ? undefined : JSON.stringify(corpo),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ── rolagem ───────────────────────────────────────────────────────────
|
|
43
|
+
Só acompanha se a pessoa JÁ estava embaixo. Puxar a tela de volta para o
|
|
44
|
+
fim enquanto alguém lê o que passou é a coisa mais irritante que um log
|
|
45
|
+
ao vivo faz. */
|
|
46
|
+
function noFim() {
|
|
47
|
+
return rolo.scrollTop + rolo.clientHeight >= rolo.scrollHeight - 60;
|
|
48
|
+
}
|
|
49
|
+
function acompanhar(estava) {
|
|
50
|
+
if (estava) rolo.scrollTop = rolo.scrollHeight;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function limparBoasVindas() {
|
|
54
|
+
var b = conversa.querySelector(".boas-vindas");
|
|
55
|
+
if (b) b.remove();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ── ANSI → HTML ───────────────────────────────────────────────────────
|
|
59
|
+
Só o que o PrimoCode realmente emite: SGR de cor 256 (38;5;N), negrito e
|
|
60
|
+
reset. O resto dos códigos (mover cursor, apagar linha) é desenho de
|
|
61
|
+
terminal e é descartado — na página quem rola é o navegador. */
|
|
62
|
+
var ANSI = /\x1b\[([0-9;?]*)([a-zA-Z])/g;
|
|
63
|
+
|
|
64
|
+
function fugir(s) {
|
|
65
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function paraHtml(texto, estado) {
|
|
69
|
+
var fora = "";
|
|
70
|
+
var ultimo = 0;
|
|
71
|
+
var m;
|
|
72
|
+
ANSI.lastIndex = 0;
|
|
73
|
+
|
|
74
|
+
function abrir() {
|
|
75
|
+
var classes = [];
|
|
76
|
+
if (estado.cor) classes.push("c" + estado.cor);
|
|
77
|
+
if (estado.forte) classes.push("forte");
|
|
78
|
+
return classes.length ? '<span class="' + classes.join(" ") + '">' : "";
|
|
79
|
+
}
|
|
80
|
+
function fechar() {
|
|
81
|
+
return (estado.cor || estado.forte) ? "</span>" : "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
while ((m = ANSI.exec(texto)) !== null) {
|
|
85
|
+
var pedaco = texto.slice(ultimo, m.index);
|
|
86
|
+
if (pedaco) fora += abrir() + fugir(pedaco) + fechar();
|
|
87
|
+
ultimo = m.index + m[0].length;
|
|
88
|
+
if (m[2] !== "m") continue; // não é cor: descarta
|
|
89
|
+
var partes = m[1].split(";");
|
|
90
|
+
for (var i = 0; i < partes.length; i++) {
|
|
91
|
+
var n = partes[i];
|
|
92
|
+
if (n === "" || n === "0") { estado.cor = null; estado.forte = false; }
|
|
93
|
+
else if (n === "1") estado.forte = true;
|
|
94
|
+
else if (n === "38" && partes[i + 1] === "5") { estado.cor = partes[i + 2]; i += 2; }
|
|
95
|
+
else if (n === "39") estado.cor = null;
|
|
96
|
+
else if (n === "22") estado.forte = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
var resto = texto.slice(ultimo);
|
|
100
|
+
if (resto) fora += abrir() + fugir(resto) + fechar();
|
|
101
|
+
return fora;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* O estado de cor atravessa os pedaços: o Node corta a saída onde der, e um
|
|
105
|
+
"\x1b[38;5;45m" pode chegar num pedaço e o texto colorido no seguinte.
|
|
106
|
+
Guardando o estado fora da função, a cor continua do outro lado do corte. */
|
|
107
|
+
var corrente = { cor: null, forte: false };
|
|
108
|
+
var bloco = null;
|
|
109
|
+
|
|
110
|
+
function escreverSaida(texto) {
|
|
111
|
+
var estava = noFim();
|
|
112
|
+
limparBoasVindas();
|
|
113
|
+
if (!bloco) {
|
|
114
|
+
bloco = document.createElement("pre");
|
|
115
|
+
bloco.className = "saida";
|
|
116
|
+
conversa.appendChild(bloco);
|
|
117
|
+
}
|
|
118
|
+
bloco.insertAdjacentHTML("beforeend", paraHtml(texto, corrente));
|
|
119
|
+
acompanhar(estava);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function escreverVoce(texto) {
|
|
123
|
+
var estava = noFim();
|
|
124
|
+
limparBoasVindas();
|
|
125
|
+
var linha = document.createElement("div");
|
|
126
|
+
linha.className = "voce";
|
|
127
|
+
var caixa = document.createElement("div");
|
|
128
|
+
caixa.textContent = texto;
|
|
129
|
+
linha.appendChild(caixa);
|
|
130
|
+
conversa.appendChild(linha);
|
|
131
|
+
bloco = null; // a próxima saída começa bloco novo
|
|
132
|
+
corrente = { cor: null, forte: false };
|
|
133
|
+
acompanhar(true);
|
|
134
|
+
void estava;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function escreverErro(texto) {
|
|
138
|
+
var estava = noFim();
|
|
139
|
+
var d = document.createElement("div");
|
|
140
|
+
d.className = "aviso-erro";
|
|
141
|
+
d.textContent = texto;
|
|
142
|
+
conversa.appendChild(d);
|
|
143
|
+
bloco = null;
|
|
144
|
+
acompanhar(estava);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── estado ────────────────────────────────────────────────────────────── */
|
|
148
|
+
|
|
149
|
+
function pintarEstado(e) {
|
|
150
|
+
if (!e) return;
|
|
151
|
+
if (e.versao) document.getElementById("versao").textContent = "v" + e.versao;
|
|
152
|
+
document.getElementById("pasta").textContent = e.pasta || "";
|
|
153
|
+
document.getElementById("modo").textContent = e.modo || "";
|
|
154
|
+
document.getElementById("esforco").textContent = e.esforco || "";
|
|
155
|
+
document.title = (e.projeto ? e.projeto + " — " : "") + "PrimoCode";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function pintarOcupado(v) {
|
|
159
|
+
ocupado = !!v;
|
|
160
|
+
pensando.hidden = !ocupado;
|
|
161
|
+
parar.hidden = !ocupado;
|
|
162
|
+
mandar.hidden = ocupado;
|
|
163
|
+
campo.placeholder = ocupado
|
|
164
|
+
? "ele está trabalhando — pode ir escrevendo o próximo"
|
|
165
|
+
: "Peça alguma coisa… (Enter manda, Shift+Enter quebra linha)";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ── o fluxo ───────────────────────────────────────────────────────────
|
|
169
|
+
EventSource reconecta sozinho quando a conexão cai, e o servidor manda o
|
|
170
|
+
que já saiu ao ligar — então uma queda de rede local não deixa buraco na
|
|
171
|
+
conversa. */
|
|
172
|
+
function ligar() {
|
|
173
|
+
var fonte = new EventSource("/api/fluxo?k=" + encodeURIComponent(CHAVE));
|
|
174
|
+
fonte.addEventListener("saida", function (ev) { escreverSaida(JSON.parse(ev.data)); });
|
|
175
|
+
fonte.addEventListener("voce", function (ev) { escreverVoce(JSON.parse(ev.data)); });
|
|
176
|
+
fonte.addEventListener("erro", function (ev) { escreverErro(JSON.parse(ev.data)); });
|
|
177
|
+
fonte.addEventListener("estado", function (ev) { pintarEstado(JSON.parse(ev.data)); });
|
|
178
|
+
fonte.addEventListener("ocupado", function (ev) { pintarOcupado(JSON.parse(ev.data)); });
|
|
179
|
+
fonte.onerror = function () { /* o EventSource se vira sozinho */ };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── mandar ────────────────────────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
function enviar(texto) {
|
|
185
|
+
texto = (texto || "").trim();
|
|
186
|
+
if (!texto) return;
|
|
187
|
+
pedir("/api/enviar", { texto: texto });
|
|
188
|
+
campo.value = "";
|
|
189
|
+
ajustarAltura();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ajustarAltura() {
|
|
193
|
+
campo.style.height = "auto";
|
|
194
|
+
campo.style.height = Math.min(campo.scrollHeight, window.innerHeight * 0.44) + "px";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
forma.addEventListener("submit", function (ev) {
|
|
198
|
+
ev.preventDefault();
|
|
199
|
+
enviar(campo.value);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
campo.addEventListener("input", ajustarAltura);
|
|
203
|
+
campo.addEventListener("keydown", function (ev) {
|
|
204
|
+
if (ev.key === "Enter" && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
|
|
205
|
+
ev.preventDefault();
|
|
206
|
+
enviar(campo.value);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
parar.addEventListener("click", function () { pedir("/api/parar", {}); });
|
|
211
|
+
|
|
212
|
+
document.addEventListener("keydown", function (ev) {
|
|
213
|
+
if (ev.key === "Escape" && ocupado) { pedir("/api/parar", {}); return; }
|
|
214
|
+
// Qualquer tecla de conteúdo devolve o foco ao campo: numa janela de chat
|
|
215
|
+
// não se clica para escrever.
|
|
216
|
+
if (ev.key.length === 1 && !ev.metaKey && !ev.ctrlKey && document.activeElement !== campo) {
|
|
217
|
+
campo.focus();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
document.querySelectorAll(".atalhos button").forEach(function (b) {
|
|
222
|
+
b.addEventListener("click", function () { enviar(b.dataset.manda); });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/* ── arrastar arquivo ──────────────────────────────────────────────────
|
|
226
|
+
"Eu quero que eu consiga arrastar os meus vídeos pra dentro do terminal,
|
|
227
|
+
e também lá no desktop."
|
|
228
|
+
|
|
229
|
+
O navegador não entrega o CAMINHO de um arquivo solto, por segurança — só
|
|
230
|
+
o conteúdo. Mas o PrimoCode roda na mesma máquina: o que ele precisa é do
|
|
231
|
+
caminho, e o nome sozinho não basta.
|
|
232
|
+
|
|
233
|
+
Duas portas, então. Soltar o arquivo escreve o nome no campo com uma
|
|
234
|
+
frase que o agente entende ("o arquivo X, que está em..."), e soltar TEXTO
|
|
235
|
+
— que é o que o Finder e o Explorer dão ao arrastar com a tecla de atalho,
|
|
236
|
+
e o que todo terminal recebe — entra como caminho de verdade. */
|
|
237
|
+
var conta = 0;
|
|
238
|
+
window.addEventListener("dragenter", function (ev) {
|
|
239
|
+
ev.preventDefault(); conta++; document.body.classList.add("soltando");
|
|
240
|
+
});
|
|
241
|
+
window.addEventListener("dragover", function (ev) { ev.preventDefault(); });
|
|
242
|
+
window.addEventListener("dragleave", function () {
|
|
243
|
+
if (--conta <= 0) { conta = 0; document.body.classList.remove("soltando"); }
|
|
244
|
+
});
|
|
245
|
+
window.addEventListener("drop", function (ev) {
|
|
246
|
+
ev.preventDefault();
|
|
247
|
+
conta = 0;
|
|
248
|
+
document.body.classList.remove("soltando");
|
|
249
|
+
|
|
250
|
+
var texto = "";
|
|
251
|
+
try { texto = ev.dataTransfer.getData("text/plain") || ""; } catch (e) {}
|
|
252
|
+
if (!texto && ev.dataTransfer.files && ev.dataTransfer.files.length) {
|
|
253
|
+
texto = Array.prototype.map.call(ev.dataTransfer.files, function (f) {
|
|
254
|
+
// Sem caminho: o nome, e o aviso de onde procurar. É o que dá para
|
|
255
|
+
// dizer com honestidade daqui.
|
|
256
|
+
return f.name;
|
|
257
|
+
}).join(" ");
|
|
258
|
+
if (texto) {
|
|
259
|
+
texto = "o arquivo " + texto + " (procure na pasta de Downloads ou na "
|
|
260
|
+
+ "Área de Trabalho; se não achar, me peça o caminho completo)";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!texto) return;
|
|
264
|
+
campo.value = (campo.value ? campo.value.trimEnd() + " " : "") + texto + " ";
|
|
265
|
+
campo.focus();
|
|
266
|
+
ajustarAltura();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
pedir("/api/estado").then(function (r) { return r.json(); }).then(pintarEstado)
|
|
270
|
+
.catch(function () {});
|
|
271
|
+
ligar();
|
|
272
|
+
campo.focus();
|
|
273
|
+
})();
|
package/app/icone.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
+
<rect width="32" height="32" rx="7" fill="#0a0f16"/>
|
|
3
|
+
<path d="M8 10.5 13 16l-5 5.5" fill="none" stroke="#22d3ee" stroke-width="2.6"
|
|
4
|
+
stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
<path d="M16.5 22H24" fill="none" stroke="#22d3ee" stroke-width="2.6" stroke-linecap="round"/>
|
|
6
|
+
</svg>
|
package/app/index.html
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="pt-BR">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>PrimoCode</title>
|
|
7
|
+
<link rel="stylesheet" href="/app.css">
|
|
8
|
+
<link rel="icon" href="/icone.svg">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
|
|
12
|
+
<header id="topo">
|
|
13
|
+
<div class="marca">
|
|
14
|
+
<svg class="logo" viewBox="0 0 24 24" aria-hidden="true">
|
|
15
|
+
<path d="M4 6.5 9 12l-5 5.5" fill="none" stroke="currentColor" stroke-width="2.1"
|
|
16
|
+
stroke-linecap="round" stroke-linejoin="round"/>
|
|
17
|
+
<path d="M12.5 18h7.5" fill="none" stroke="currentColor" stroke-width="2.1"
|
|
18
|
+
stroke-linecap="round"/>
|
|
19
|
+
</svg>
|
|
20
|
+
<span class="nome">PrimoCode</span>
|
|
21
|
+
<span class="versao" id="versao"></span>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="estado">
|
|
24
|
+
<span class="chip" id="pasta" title="pasta de trabalho"></span>
|
|
25
|
+
<span class="chip" id="modo"></span>
|
|
26
|
+
<span class="chip" id="esforco"></span>
|
|
27
|
+
</div>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<main id="rolo">
|
|
31
|
+
<div id="conversa">
|
|
32
|
+
<div class="boas-vindas">
|
|
33
|
+
<h1>Oi. O que a gente vai construir?</h1>
|
|
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>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</main>
|
|
45
|
+
|
|
46
|
+
<footer id="baixo">
|
|
47
|
+
<div id="pensando" hidden><span class="pulso"></span><span id="pensando-txt">trabalhando</span></div>
|
|
48
|
+
<form id="forma">
|
|
49
|
+
<textarea id="campo" rows="1" placeholder="Peça alguma coisa… (Enter manda, Shift+Enter quebra linha)"
|
|
50
|
+
autocomplete="off" spellcheck="false"></textarea>
|
|
51
|
+
<button type="submit" id="mandar" title="Enviar (Enter)" aria-label="Enviar">
|
|
52
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
53
|
+
<path d="M4 12h13m0 0-5-5m5 5-5 5" fill="none" stroke="currentColor"
|
|
54
|
+
stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>
|
|
55
|
+
</svg>
|
|
56
|
+
</button>
|
|
57
|
+
<button type="button" id="parar" title="Parar (Esc)" hidden aria-label="Parar">
|
|
58
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg>
|
|
59
|
+
</button>
|
|
60
|
+
</form>
|
|
61
|
+
<div class="dica" id="dica">arraste um vídeo ou arquivo aqui para o PrimoCode usar</div>
|
|
62
|
+
</footer>
|
|
63
|
+
|
|
64
|
+
<div id="alvo-solto"><div><strong>Solte aqui</strong><span>o caminho entra no pedido</span></div></div>
|
|
65
|
+
|
|
66
|
+
<script src="/app.js"></script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
package/bin/primocode.js
CHANGED
|
@@ -25,6 +25,7 @@ const pasta = require('../lib/pasta');
|
|
|
25
25
|
const catalogo = require('../lib/catalogo');
|
|
26
26
|
const effort = require('../lib/effort');
|
|
27
27
|
const memoria = require('../lib/memoria-conversa');
|
|
28
|
+
const appLib = require('../lib/app');
|
|
28
29
|
|
|
29
30
|
// Rede de segurança de último recurso: sem isto, uma exceção fora de um
|
|
30
31
|
// try/catch (ex.: dentro de um callback de socket, como o parser de SSE) não
|
|
@@ -61,6 +62,10 @@ const state = {
|
|
|
61
62
|
engine: 'server', // 'server' (OpenRouter/Groq) | 'claude-code' (/claude)
|
|
62
63
|
};
|
|
63
64
|
|
|
65
|
+
// A janela do /app, quando aberta. Uma por sessão: o segundo /app traz a que
|
|
66
|
+
// existe para a frente em vez de subir outro servidor ao lado.
|
|
67
|
+
let janelaAberta = null;
|
|
68
|
+
|
|
64
69
|
const HELP = `
|
|
65
70
|
${c.bold('Comandos')}
|
|
66
71
|
${c.brand('/dir')} <caminho> trabalha numa pasta que já existe (onde você quiser)
|
|
@@ -75,6 +80,7 @@ ${c.bold('Comandos')}
|
|
|
75
80
|
${c.brand('/read')} <arquivo> mostra um arquivo
|
|
76
81
|
${c.brand('/run')} <comando> executa no shell do projeto
|
|
77
82
|
${c.brand('/open')} <arquivo|url> abre no navegador
|
|
83
|
+
${c.brand('/app')} abre a janela do PrimoCode (chat, sem terminal)
|
|
78
84
|
${c.brand('/status')} checa o servidor
|
|
79
85
|
${c.brand('/name')} <nome> como o Primo te chama
|
|
80
86
|
${c.brand('/mascot')} <estilo> mascote: primo|fox|cat
|
|
@@ -115,6 +121,7 @@ const SLASH_COMANDOS = [
|
|
|
115
121
|
['/read', 'mostra um arquivo'],
|
|
116
122
|
['/run', 'roda um comando'],
|
|
117
123
|
['/open', 'abre no navegador'],
|
|
124
|
+
['/app', 'abre a janela do PrimoCode'],
|
|
118
125
|
['/status', 'checa o servidor'],
|
|
119
126
|
['/name', 'como ele te chama'],
|
|
120
127
|
['/mascot', 'primo | fox | cat'],
|
|
@@ -925,6 +932,51 @@ async function handleSlash(text) {
|
|
|
925
932
|
break;
|
|
926
933
|
}
|
|
927
934
|
|
|
935
|
+
/* A janela. "Se mandar /app pra ele, ele abre o aplicativo — para o
|
|
936
|
+
Mac e o Windows, já instalado junto com o npm, com as cores do
|
|
937
|
+
PrimoCode. Um chat estilo terminal para quem não quer o terminal."
|
|
938
|
+
|
|
939
|
+
Uma janela por sessão: mandar /app de novo traz a que já existe
|
|
940
|
+
para a frente em vez de abrir outra do lado. */
|
|
941
|
+
case 'app': {
|
|
942
|
+
if (janelaAberta) {
|
|
943
|
+
// Já existe: reabre no MESMO endereço. O navegador traz a
|
|
944
|
+
// janela daquela URL para a frente em vez de fazer outra.
|
|
945
|
+
await appLib.abrirJanela(janelaAberta.url);
|
|
946
|
+
console.log(c.muted(' A janela já estava aberta — trouxe para a frente.'));
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
const sp = new Spinner('abrindo a janela').start();
|
|
950
|
+
try {
|
|
951
|
+
janelaAberta = await appLib.abrir({
|
|
952
|
+
versao: VERSION,
|
|
953
|
+
enviar: (t) => sendPrompt(t),
|
|
954
|
+
barra: (t) => handleSlash(t),
|
|
955
|
+
interromper,
|
|
956
|
+
estado: () => ({
|
|
957
|
+
pasta: state.workDir ? shortPath(state.workDir) : null,
|
|
958
|
+
projeto: state.currentProject,
|
|
959
|
+
modo: state.mode,
|
|
960
|
+
esforco: state.config.effort,
|
|
961
|
+
}),
|
|
962
|
+
});
|
|
963
|
+
sp.stop();
|
|
964
|
+
const j = janelaAberta.janela || {};
|
|
965
|
+
console.log(c.ok(' Janela aberta.')
|
|
966
|
+
+ c.muted(j.modo === 'janela' ? ` (${j.navegador})`
|
|
967
|
+
: j.modo === 'aba' ? ' (aba do navegador padrão)' : ''));
|
|
968
|
+
console.log(c.dim(` ${janelaAberta.url}`));
|
|
969
|
+
if (j.modo === 'nenhum') {
|
|
970
|
+
console.log(c.warn(' Não consegui abrir sozinho — copie o endereço acima no navegador.'));
|
|
971
|
+
}
|
|
972
|
+
console.log(c.muted(' O terminal continua funcionando: os dois mostram a mesma coisa.'));
|
|
973
|
+
} catch (e) {
|
|
974
|
+
sp.stop();
|
|
975
|
+
console.log(c.err(` Não subiu: ${e.message}`));
|
|
976
|
+
}
|
|
977
|
+
break;
|
|
978
|
+
}
|
|
979
|
+
|
|
928
980
|
case 'uninstall': {
|
|
929
981
|
if (await confirm('Remover o PrimoCode? Seus projetos em ~/primocode NÃO serão apagados.')) {
|
|
930
982
|
await tools.uninstall();
|
package/lib/app.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app.js — o PrimoCode como aplicativo de janela.
|
|
3
|
+
*
|
|
4
|
+
* "Se mandar /app pra ele, ele abre o aplicativo que você vai desenvolver
|
|
5
|
+
* para o Mac e o Windows, já instala junto com o npm. Vai ser um aplicativo
|
|
6
|
+
* desktop bem bonito com as cores do PrimoCode, com aquele azul. Bem estilo
|
|
7
|
+
* Codex. Vai ter o chat em localhost. Um chat estilo terminal para quem não
|
|
8
|
+
* quer o terminal."
|
|
9
|
+
*
|
|
10
|
+
* ── POR QUE NÃO É ELECTRON ───────────────────────────────────────────────
|
|
11
|
+
* O PrimoCode tem ZERO dependência de npm, e isso não é purismo: é o que faz
|
|
12
|
+
* `npm i -g primocode` levar segundos em vez de baixar 200 MB de navegador
|
|
13
|
+
* empacotado. Electron levaria os dois embora — a promessa de "já instala
|
|
14
|
+
* junto com o npm" e a instalação leve.
|
|
15
|
+
*
|
|
16
|
+
* O que Electron entrega, no fundo, é uma janela sem barra de endereço
|
|
17
|
+
* mostrando uma página local. Chrome, Edge e Brave fazem exatamente isso com
|
|
18
|
+
* `--app=<url>`: janela limpa, ícone próprio na barra de tarefas, sem abas.
|
|
19
|
+
* O navegador já está na máquina do usuário — no Windows o Edge vem com o
|
|
20
|
+
* sistema, no Mac o Safari (e quase sempre o Chrome).
|
|
21
|
+
*
|
|
22
|
+
* Então: servidor HTTP local (http do Node, nada instalado) + janela do
|
|
23
|
+
* navegador em modo app. Se não houver nenhum navegador de Chromium, abre
|
|
24
|
+
* numa aba normal, que continua funcionando.
|
|
25
|
+
*
|
|
26
|
+
* ── POR QUE ELE ESPELHA O TERMINAL ───────────────────────────────────────
|
|
27
|
+
* O pedido foi "um chat estilo terminal para quem não quer o terminal" — e o
|
|
28
|
+
* agente já conta o que faz, linha por linha, com cor. Reescrever essa saída
|
|
29
|
+
* para a web daria duas verdades para manter, e uma delas ficaria velha.
|
|
30
|
+
*
|
|
31
|
+
* Aqui a saída é a MESMA: o `process.stdout.write` é espelhado, os códigos
|
|
32
|
+
* ANSI viram cor de verdade na página, e o terminal continua imprimindo
|
|
33
|
+
* normalmente. Quem abrir os dois vê a mesma coisa nos dois.
|
|
34
|
+
*
|
|
35
|
+
* ── SEGURANÇA ────────────────────────────────────────────────────────────
|
|
36
|
+
* Este servidor MANDA NO AGENTE: quem fala com ele executa comando, escreve
|
|
37
|
+
* arquivo e gasta a cota do usuário. Por isso ele só escuta em 127.0.0.1 e
|
|
38
|
+
* exige uma chave sorteada a cada abertura — sem ela, uma página qualquer
|
|
39
|
+
* aberta no mesmo navegador dispararia pedidos para cá.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
const http = require('http');
|
|
45
|
+
const fs = require('fs');
|
|
46
|
+
const os = require('os');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
const crypto = require('crypto');
|
|
49
|
+
const { execFile } = require('child_process');
|
|
50
|
+
|
|
51
|
+
const WEB = path.join(__dirname, '..', 'app');
|
|
52
|
+
const PORTA_PADRAO = Number(process.env.PRIMOCODE_APP_PORT) || 7788;
|
|
53
|
+
|
|
54
|
+
// ── espelho da saída ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/* Duplica tudo que vai para o terminal, sem tirar nada de lá.
|
|
57
|
+
*
|
|
58
|
+
* O `write` original continua sendo chamado com os mesmos argumentos e o mesmo
|
|
59
|
+
* retorno: se este espelho mudasse o valor de volta, o Node passaria a achar
|
|
60
|
+
* que o buffer encheu (ou que não encheu) e a saída sairia picada. */
|
|
61
|
+
function espelhar(aoEscrever) {
|
|
62
|
+
const real = process.stdout.write.bind(process.stdout);
|
|
63
|
+
process.stdout.write = function (pedaco, enc, cb) {
|
|
64
|
+
try {
|
|
65
|
+
aoEscrever(typeof pedaco === 'string' ? pedaco : Buffer.from(pedaco).toString('utf8'));
|
|
66
|
+
} catch { /* o espelho nunca pode derrubar a saída */ }
|
|
67
|
+
return real(pedaco, enc, cb);
|
|
68
|
+
};
|
|
69
|
+
return function parar() { process.stdout.write = real; };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* O prompt do readline e os quadros do rodinha não são conteúdo: são a
|
|
73
|
+
interface do TERMINAL se redesenhando. Na página eles viravam lixo piscando
|
|
74
|
+
no meio da conversa. O jeito de reconhecê-los é o que sobra depois de tirar
|
|
75
|
+
o ANSI: pedaço curto e sem quebra de linha é desenho, não texto. */
|
|
76
|
+
function ehRuido(texto) {
|
|
77
|
+
const limpo = texto.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\r/g, '');
|
|
78
|
+
if (limpo.includes('\n')) return false;
|
|
79
|
+
return limpo.trim().length < 12;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── o servidor ───────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function tipoDe(arquivo) {
|
|
85
|
+
if (arquivo.endsWith('.html')) return 'text/html; charset=utf-8';
|
|
86
|
+
if (arquivo.endsWith('.css')) return 'text/css; charset=utf-8';
|
|
87
|
+
if (arquivo.endsWith('.js')) return 'application/javascript; charset=utf-8';
|
|
88
|
+
if (arquivo.endsWith('.svg')) return 'image/svg+xml';
|
|
89
|
+
return 'application/octet-stream';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Sobe a janela do PrimoCode.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} op
|
|
96
|
+
* @param {(texto:string)=>Promise} op.enviar manda um pedido ao agente
|
|
97
|
+
* @param {(texto:string)=>Promise} op.barra trata um comando /barra
|
|
98
|
+
* @param {()=>void} op.interromper para o que estiver rodando
|
|
99
|
+
* @param {()=>object} op.estado { pasta, modo, esforco, projeto }
|
|
100
|
+
* @param {string} op.versao
|
|
101
|
+
*/
|
|
102
|
+
async function abrir(op) {
|
|
103
|
+
const chave = crypto.randomBytes(16).toString('hex');
|
|
104
|
+
const ouvintes = new Set();
|
|
105
|
+
let ocupado = false;
|
|
106
|
+
const fila = [];
|
|
107
|
+
|
|
108
|
+
// O histórico de saída para quem abrir a janela DEPOIS de o agente já ter
|
|
109
|
+
// começado — sem isto a página nasceria vazia no meio de uma tarefa.
|
|
110
|
+
const memoria = [];
|
|
111
|
+
const MEMORIA_MAX = 400;
|
|
112
|
+
|
|
113
|
+
function emitir(evento, dados) {
|
|
114
|
+
const linha = `event: ${evento}\ndata: ${JSON.stringify(dados)}\n\n`;
|
|
115
|
+
for (const res of ouvintes) {
|
|
116
|
+
try { res.write(linha); } catch { ouvintes.delete(res); }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pararEspelho = espelhar((pedaco) => {
|
|
121
|
+
if (ehRuido(pedaco)) return;
|
|
122
|
+
memoria.push(pedaco);
|
|
123
|
+
if (memoria.length > MEMORIA_MAX) memoria.shift();
|
|
124
|
+
emitir('saida', pedaco);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
async function processar(texto) {
|
|
128
|
+
fila.push(texto);
|
|
129
|
+
if (ocupado) return;
|
|
130
|
+
ocupado = true;
|
|
131
|
+
emitir('ocupado', true);
|
|
132
|
+
while (fila.length) {
|
|
133
|
+
const t = String(fila.shift() || '').trim();
|
|
134
|
+
if (!t) continue;
|
|
135
|
+
emitir('voce', t);
|
|
136
|
+
try {
|
|
137
|
+
if (t.startsWith('/')) await op.barra(t);
|
|
138
|
+
else await op.enviar(t);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
emitir('erro', (e && e.message) || String(e));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
ocupado = false;
|
|
144
|
+
emitir('ocupado', false);
|
|
145
|
+
emitir('estado', op.estado());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const servidor = http.createServer((req, res) => {
|
|
149
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
150
|
+
const rota = url.pathname;
|
|
151
|
+
|
|
152
|
+
/* A chave protege a API, que é onde está tudo o que importa: a conversa
|
|
153
|
+
do usuário e a capacidade de dar ordens ao agente. A página em si —
|
|
154
|
+
HTML, CSS, JS — não guarda segredo nenhum e não faz nada sem a
|
|
155
|
+
chave, então ela é servida solta.
|
|
156
|
+
|
|
157
|
+
Não é frouxidão, é o contrário: exigir chave nos estáticos também
|
|
158
|
+
quebrava a própria janela, porque o navegador pede /app.css e
|
|
159
|
+
/app.js sem parâmetro nenhum. A página vinha, ficava sem estilo e
|
|
160
|
+
sem script, e o "app bonito" era um formulário branco. */
|
|
161
|
+
if (rota.startsWith('/api/')) {
|
|
162
|
+
const dada = url.searchParams.get('k') || req.headers['x-primo-chave'];
|
|
163
|
+
if (dada !== chave) {
|
|
164
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
165
|
+
return res.end('chave inválida — abra a janela com o comando /app');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/* CSRF: uma página de terceiro consegue disparar POST simples para
|
|
169
|
+
127.0.0.1 sem preflight. Ela não saberia a chave, mas o cinto e o
|
|
170
|
+
suspensório custam três linhas. */
|
|
171
|
+
const origem = req.headers.origin;
|
|
172
|
+
if (origem && !origem.startsWith(`http://127.0.0.1:${servidor.__porta}`)
|
|
173
|
+
&& !origem.startsWith(`http://localhost:${servidor.__porta}`)) {
|
|
174
|
+
res.writeHead(403).end();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (rota === '/api/fluxo') {
|
|
179
|
+
res.writeHead(200, {
|
|
180
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
181
|
+
'Cache-Control': 'no-cache',
|
|
182
|
+
Connection: 'keep-alive',
|
|
183
|
+
// Sem isto um proxy no meio segura os eventos até fechar.
|
|
184
|
+
'X-Accel-Buffering': 'no',
|
|
185
|
+
});
|
|
186
|
+
res.write(': ligado\n\n');
|
|
187
|
+
ouvintes.add(res);
|
|
188
|
+
res.write(`event: estado\ndata: ${JSON.stringify(op.estado())}\n\n`);
|
|
189
|
+
for (const pedaco of memoria) {
|
|
190
|
+
res.write(`event: saida\ndata: ${JSON.stringify(pedaco)}\n\n`);
|
|
191
|
+
}
|
|
192
|
+
res.write(`event: ocupado\ndata: ${JSON.stringify(ocupado)}\n\n`);
|
|
193
|
+
// Batida a cada 20s: proxy e sistema derrubam conexão parada, e
|
|
194
|
+
// uma janela que "morre sozinha depois de um tempo" seria um bug
|
|
195
|
+
// impossível de achar depois.
|
|
196
|
+
const batida = setInterval(() => { try { res.write(': ok\n\n'); } catch {} }, 20000);
|
|
197
|
+
req.on('close', () => { clearInterval(batida); ouvintes.delete(res); });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (rota === '/api/enviar' && req.method === 'POST') {
|
|
202
|
+
let corpo = '';
|
|
203
|
+
req.on('data', (c) => { corpo += c; if (corpo.length > 200000) req.destroy(); });
|
|
204
|
+
req.on('end', () => {
|
|
205
|
+
let texto = '';
|
|
206
|
+
try { texto = JSON.parse(corpo).texto || ''; } catch { /* vazio */ }
|
|
207
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
|
|
208
|
+
if (texto.trim()) processar(texto);
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (rota === '/api/parar' && req.method === 'POST') {
|
|
214
|
+
try { op.interromper(); } catch {}
|
|
215
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (rota === '/api/estado') {
|
|
220
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
221
|
+
return res.end(JSON.stringify(Object.assign({ versao: op.versao }, op.estado())));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Estáticos. `basename` corta qualquer "../" antes de virar caminho:
|
|
225
|
+
// este servidor fala com o disco do usuário e não pode servir o que
|
|
226
|
+
// estiver fora da pasta do aplicativo.
|
|
227
|
+
const nome = rota === '/' ? 'index.html' : path.basename(rota);
|
|
228
|
+
const arquivo = path.join(WEB, nome);
|
|
229
|
+
fs.readFile(arquivo, (erro, dados) => {
|
|
230
|
+
if (erro) { res.writeHead(404).end('não encontrado'); return; }
|
|
231
|
+
res.writeHead(200, { 'Content-Type': tipoDe(nome) });
|
|
232
|
+
res.end(dados);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const porta = await escutar(servidor, PORTA_PADRAO);
|
|
237
|
+
servidor.__porta = porta;
|
|
238
|
+
const url = `http://127.0.0.1:${porta}/?k=${chave}`;
|
|
239
|
+
|
|
240
|
+
const janela = await abrirJanela(url);
|
|
241
|
+
return {
|
|
242
|
+
url, porta, chave, servidor, janela,
|
|
243
|
+
fechar() { try { pararEspelho(); } catch {} try { servidor.close(); } catch {} },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* Porta ocupada não é erro: é outra janela do PrimoCode, ou qualquer coisa
|
|
248
|
+
que pegou a 7788 primeiro. Anda dez casas e desiste — se dez portas
|
|
249
|
+
seguidas estão tomadas, o problema é outro. */
|
|
250
|
+
function escutar(servidor, inicial) {
|
|
251
|
+
return new Promise((ok, falha) => {
|
|
252
|
+
let porta = inicial;
|
|
253
|
+
const tentar = () => {
|
|
254
|
+
servidor.once('error', (e) => {
|
|
255
|
+
if (e.code === 'EADDRINUSE' && porta < inicial + 10) { porta++; tentar(); }
|
|
256
|
+
else falha(e);
|
|
257
|
+
});
|
|
258
|
+
servidor.listen(porta, '127.0.0.1', () => ok(porta));
|
|
259
|
+
};
|
|
260
|
+
tentar();
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* ── a janela ────────────────────────────────────────────────────────────
|
|
265
|
+
`--app=` abre sem barra de endereço, sem abas e com entrada própria na
|
|
266
|
+
barra de tarefas: é o que faz parecer aplicativo em vez de site. Chrome,
|
|
267
|
+
Edge, Brave e Vivaldi entendem, porque todos são Chromium.
|
|
268
|
+
|
|
269
|
+
`--user-data-dir` aponta para uma pasta do PrimoCode de propósito: sem ele,
|
|
270
|
+
o Chrome que JÁ ESTÁ ABERTO recebe o pedido e devolve uma aba comum — o
|
|
271
|
+
modo app é ignorado quando há um perfil em uso. Com perfil próprio nasce
|
|
272
|
+
uma janela de verdade, e o histórico do usuário fica fora disto. */
|
|
273
|
+
function candidatosDeNavegador() {
|
|
274
|
+
const p = os.platform();
|
|
275
|
+
if (p === 'darwin') {
|
|
276
|
+
return [
|
|
277
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
278
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
279
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
280
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
281
|
+
'/Applications/Arc.app/Contents/MacOS/Arc',
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
if (p === 'win32') {
|
|
285
|
+
const bases = [process.env['PROGRAMFILES'], process.env['PROGRAMFILES(X86)'],
|
|
286
|
+
process.env.LOCALAPPDATA].filter(Boolean);
|
|
287
|
+
const relativos = [
|
|
288
|
+
'Google\\Chrome\\Application\\chrome.exe',
|
|
289
|
+
'Microsoft\\Edge\\Application\\msedge.exe',
|
|
290
|
+
'BraveSoftware\\Brave-Browser\\Application\\brave.exe',
|
|
291
|
+
];
|
|
292
|
+
const fora = [];
|
|
293
|
+
for (const b of bases) for (const r of relativos) fora.push(path.join(b, r));
|
|
294
|
+
return fora;
|
|
295
|
+
}
|
|
296
|
+
return ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium',
|
|
297
|
+
'/usr/bin/chromium-browser', '/usr/bin/microsoft-edge', '/usr/bin/brave-browser'];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function abrirJanela(url) {
|
|
301
|
+
const perfil = path.join(os.homedir(), '.primocode', 'janela');
|
|
302
|
+
try { fs.mkdirSync(perfil, { recursive: true }); } catch {}
|
|
303
|
+
|
|
304
|
+
for (const exe of candidatosDeNavegador()) {
|
|
305
|
+
if (!fs.existsSync(exe)) continue;
|
|
306
|
+
try {
|
|
307
|
+
const filho = execFile(exe, [
|
|
308
|
+
`--app=${url}`,
|
|
309
|
+
`--user-data-dir=${perfil}`,
|
|
310
|
+
'--window-size=1180,820',
|
|
311
|
+
'--no-first-run',
|
|
312
|
+
'--no-default-browser-check',
|
|
313
|
+
], () => {});
|
|
314
|
+
// A janela é do usuário: se o PrimoCode do terminal terminar, ela
|
|
315
|
+
// continua de pé.
|
|
316
|
+
filho.unref();
|
|
317
|
+
return Promise.resolve({ modo: 'janela', navegador: path.basename(exe) });
|
|
318
|
+
} catch { /* tenta o próximo */ }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Nenhum Chromium instalado: aba comum no navegador padrão. Funciona
|
|
322
|
+
// igual — só não fica com cara de aplicativo.
|
|
323
|
+
const p = os.platform();
|
|
324
|
+
const [cmd, args] = p === 'darwin' ? ['open', [url]]
|
|
325
|
+
: p === 'win32' ? ['cmd.exe', ['/c', 'start', '', url]]
|
|
326
|
+
: ['xdg-open', [url]];
|
|
327
|
+
return new Promise((ok) => {
|
|
328
|
+
execFile(cmd, args, { timeout: 10000 }, (erro) => {
|
|
329
|
+
ok(erro ? { modo: 'nenhum', erro: erro.message } : { modo: 'aba' });
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.26.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": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/primocode.js",
|
|
11
|
-
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/app.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"bin/",
|
|
29
29
|
"lib/",
|
|
30
|
+
"app/",
|
|
30
31
|
"studio/",
|
|
31
32
|
"README.md"
|
|
32
33
|
]
|