primocode 11.2.0 → 11.3.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/README.md +2 -2
- package/lib/agentes-pessoais.js +5 -2
- package/lib/app-server/cli.js +6 -0
- package/lib/app-server/enderecos.js +43 -0
- package/lib/app-server/servidor.js +174 -31
- package/lib/avisos.js +1 -0
- package/lib/cli/sessao.js +174 -16
- package/lib/config/config.js +16 -1
- package/lib/diff.js +12 -2
- package/lib/motor/fala-viva.js +77 -0
- package/lib/motor/processo-vivo.js +66 -0
- package/lib/motor/transcricao.js +81 -0
- package/lib/plugin/entregaveis/nucleo.js +2 -2
- package/lib/plugin/estudio/gosto.js +3 -0
- package/lib/plugin/estudio/kits/tela.html +3 -1
- package/lib/projetos-meta.js +20 -3
- package/lib/session/runner/act.js +23 -4
- package/lib/tool/tools.js +1 -0
- package/lib/tui/entrada.js +143 -18
- package/lib/tui/tela.js +312 -49
- package/lib/tui/ui.js +77 -3
- package/package.json +6 -2
- package/studio/voz_kokoro_vivo.py +46 -0
- package/voz/primo_voz/transcrever.py +37 -0
package/lib/cli/sessao.js
CHANGED
|
@@ -13,6 +13,7 @@ const os = require('os');
|
|
|
13
13
|
|
|
14
14
|
const pkg = require('../../package.json');
|
|
15
15
|
const ui = require('../tui/ui');
|
|
16
|
+
const tela = require('../tui/tela.js');
|
|
16
17
|
const entrada = require('../tui/entrada');
|
|
17
18
|
const { c, welcomeBox, Spinner } = ui;
|
|
18
19
|
const { escolherModo } = require('../modo.js');
|
|
@@ -444,9 +445,10 @@ function barraDeStatus() {
|
|
|
444
445
|
direita.push(...SL.partes({ modelo: SL.modelo(), ctxPct: uso.pct, tokensHoje: SL.tokensDeHoje(), c }));
|
|
445
446
|
const aprovar = require('../cli-nova.js').praAprovar();
|
|
446
447
|
if (aprovar) direita.push(c.warn(`${aprovar} pra aprovar`));
|
|
447
|
-
|
|
448
|
+
const atalhos = state.trabalhando && rl && rl.ocupado
|
|
448
449
|
? c.warn('trabalhando') + c.dim(' · ') + c.white('esc esc') + c.dim(' para')
|
|
449
|
-
: c.white('tab') + c.dim(' modos ') + c.white('ctrl+p') + c.dim(' comandos')
|
|
450
|
+
: c.white('tab') + c.dim(' modos ') + c.white('ctrl+p') + c.dim(' comandos');
|
|
451
|
+
direita.push(atalhos);
|
|
450
452
|
|
|
451
453
|
/* O modo DENTRO do painel. No auto, depois do primeiro pedido, ele diz
|
|
452
454
|
qual modo acabou respondendo — senão você nunca sabe quem respondeu. */
|
|
@@ -455,9 +457,12 @@ function barraDeStatus() {
|
|
|
455
457
|
if (state.mode === 'auto' && state.modoEfetivo) {
|
|
456
458
|
modo += c.dim(' → ') + c.muted(NOME_MODO[state.modoEfetivo] || state.modoEfetivo);
|
|
457
459
|
}
|
|
458
|
-
|
|
460
|
+
/* "Build · Primo Max · esforço alto": o modo, o plano da conta ("Primo Max") e o esforço. */
|
|
461
|
+
let planoNome = '';
|
|
462
|
+
try { const eu = conta.atual(); if (eu && eu.plano && eu.plano !== 'free') planoNome = ' ' + String(eu.plano).replace(/^./, (x) => x.toUpperCase()); } catch { /* sem conta */ }
|
|
463
|
+
modo += c.dim(' · ') + c.white('Primo' + planoNome) + c.dim(' · esforço ') + c.white(effort.normalizar(state.config.effort));
|
|
459
464
|
|
|
460
|
-
return { modo, cor, esquerda: partes.join(c.dim(' · ')), direita: direita.join(c.dim(' ')) };
|
|
465
|
+
return { modo, cor, esquerda: partes.join(c.dim(' · ')), direita: direita.join(c.dim(' ')), atalhos };
|
|
461
466
|
}
|
|
462
467
|
|
|
463
468
|
/* A barra da direita da tela cheia, como a do OpenCode: o contexto (quantos
|
|
@@ -468,20 +473,61 @@ function barraLateral() {
|
|
|
468
473
|
const dir = state.workDir || process.cwd();
|
|
469
474
|
const ramo = ramoDoGit(dir);
|
|
470
475
|
const m = state.mode === 'auto' && state.modoEfetivo ? state.modoEfetivo : state.mode;
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
{
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
476
|
+
const larg = 22;
|
|
477
|
+
const secoes = [];
|
|
478
|
+
|
|
479
|
+
// Contexto: quanto da conversa já foi gasto, com a barra.
|
|
480
|
+
secoes.push({ titulo: 'Contexto', linhas: [
|
|
481
|
+
c.muted(`${uso.tokens.toLocaleString('pt-BR')} tokens · ${uso.pct}%`),
|
|
482
|
+
tela.barraDe(uso.pct, larg, uso.pct >= 100 ? c.err : uso.pct >= 70 ? c.warn : c.brand),
|
|
483
|
+
] });
|
|
484
|
+
|
|
485
|
+
/* Plano: o plano da CONTA (o que o login confirmou) e o gasto de hoje contra o limite que a
|
|
486
|
+
pessoa ensinou (/usage limite). O servidor não informa o limite da semana, então ele não é inventado. */
|
|
487
|
+
try {
|
|
488
|
+
const eu = conta.atual();
|
|
489
|
+
const L = require('../config/limite.js');
|
|
490
|
+
const hoje = L.usoDeHoje().tokens;
|
|
491
|
+
const lim = L.limiteConhecido();
|
|
492
|
+
const linhas = [];
|
|
493
|
+
if (eu && eu.plano) linhas.push(c.muted(`Plano ${String(eu.plano).replace(/^./, (x) => x.toUpperCase())}`));
|
|
494
|
+
if (lim) {
|
|
495
|
+
const pct = Math.round((hoje / lim) * 100);
|
|
496
|
+
linhas.push(c.muted(`${pct}% do limite de hoje`), tela.barraDe(pct, larg, pct >= 100 ? c.err : pct >= 70 ? c.warn : c.brand));
|
|
497
|
+
} else linhas.push(c.dim(`${hoje.toLocaleString('pt-BR')} tokens hoje`));
|
|
498
|
+
secoes.push({ titulo: 'Plano', linhas });
|
|
499
|
+
} catch { /* o plano nunca derruba a tela */ }
|
|
500
|
+
|
|
501
|
+
secoes.push({ titulo: 'Modo', linhas: [
|
|
502
|
+
`${COR_MODO[m] || COR_MODO.auto}${NOME_MODO[m] || m}${c.reset}` + c.muted(` · esforço ${effort.normalizar(state.config.effort)}`),
|
|
503
|
+
] });
|
|
504
|
+
|
|
505
|
+
// Tarefas: o write_todos do agente.
|
|
506
|
+
const todos = ui.estadoSessao.todos || [];
|
|
507
|
+
if (todos.length) {
|
|
508
|
+
const marca = { completed: c.ok('✓'), in_progress: c.brand('●'), pending: c.dim('○') };
|
|
509
|
+
secoes.push({ titulo: 'Tarefas', linhas: todos.slice(0, 8).map((t) => `${marca[t.status] || c.dim('○')} ` + (t.status === 'completed' ? c.dim(t.content) : t.status === 'in_progress' ? c.white(t.content) : c.muted(t.content))) });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Arquivos alterados, com +N −M.
|
|
513
|
+
if (ui.estadoSessao.arquivos.size) {
|
|
514
|
+
const arqs = [...ui.estadoSessao.arquivos.entries()].slice(-8);
|
|
515
|
+
secoes.push({ titulo: 'Arquivos alterados', linhas: arqs.map(([f, k]) =>
|
|
516
|
+
c.muted(path.basename(f)) + ' ' + (k.mais ? c.ok('+' + k.mais) : '') + (k.menos ? ' ' + c.err('−' + k.menos) : '')) });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// MCP: os servidores configurados (o padrão, o do usuário, o do projeto).
|
|
520
|
+
try {
|
|
521
|
+
const nomes = Object.keys(require('../mcp/mcp.js').servidores(dir));
|
|
522
|
+
if (nomes.length) secoes.push({ titulo: 'MCP', linhas: nomes.slice(0, 5).map((n) => c.ok('●') + ' ' + c.muted(n)) });
|
|
523
|
+
} catch { /* sem MCP, sem seção */ }
|
|
524
|
+
|
|
480
525
|
if (state.attachedFiles.length) {
|
|
481
526
|
secoes.push({ titulo: 'Anexos', linhas: state.attachedFiles.map((f) => c.muted(path.basename(f.path))) });
|
|
482
527
|
}
|
|
483
528
|
if (state.controle && state.controle.liberado) secoes.push({ titulo: 'Controle do PC', linhas: [c.warn('liberado até fechar')] });
|
|
484
529
|
return {
|
|
530
|
+
subtitulo: state.currentProject ? state.currentProject : undefined,
|
|
485
531
|
secoes,
|
|
486
532
|
rodape: [
|
|
487
533
|
c.muted(shortPath(dir)) + (ramo ? c.white(':' + ramo) : ''),
|
|
@@ -490,6 +536,83 @@ function barraLateral() {
|
|
|
490
536
|
};
|
|
491
537
|
}
|
|
492
538
|
|
|
539
|
+
/* ── A PALETA (ctrl+p) E OS DIÁLOGOS ─────────────────────────────────────
|
|
540
|
+
* O desenho é do OpenCode: uma lista com busca no meio da tela. O conteúdo é
|
|
541
|
+
* o que já existe aqui: os comandos de barra, os modos, os agentes da pessoa,
|
|
542
|
+
* as conversas salvas e os temas. Escolher um item roda o comando de sempre. */
|
|
543
|
+
const ATALHOS_DO_LIDER = { '/clear': 'ctrl+x n', '/projects': 'ctrl+x p', '/estudio': 'ctrl+x s' };
|
|
544
|
+
|
|
545
|
+
function paletaDeComandos() {
|
|
546
|
+
const itens = [
|
|
547
|
+
{ secao: 'Sugeridos', nome: 'Nova conversa', cmd: '/clear', tecla: 'ctrl+x n' },
|
|
548
|
+
{ secao: 'Sugeridos', nome: 'Trocar o modo e o esforço', desc: 'auto · primo code · chat', tecla: 'ctrl+x m', acao: (r) => r.abrirDialogo(dialogoModelo()) },
|
|
549
|
+
{ secao: 'Sugeridos', nome: 'Agentes', desc: 'ligar e desligar', tecla: 'ctrl+x a', acao: (r) => r.abrirDialogo(dialogoAgentes()) },
|
|
550
|
+
{ secao: 'Sugeridos', nome: 'Conversas salvas', tecla: 'ctrl+x l', acao: (r) => dialogoSessoes().then((d) => d && r.abrirDialogo(d)) },
|
|
551
|
+
{ secao: 'Sugeridos', nome: 'Tema', desc: 'cor de destaque', tecla: 'ctrl+x t', acao: (r) => r.abrirDialogo(dialogoTema()) },
|
|
552
|
+
{ secao: 'Sugeridos', nome: 'Mostrar ou esconder a barra da direita', tecla: 'ctrl+x b', acao: (r) => { r.lateralOculta = !r.lateralOculta; r._quadroAnterior = null; r._pintar(); } },
|
|
553
|
+
];
|
|
554
|
+
for (const [nome, desc] of comandosDoMenu()) {
|
|
555
|
+
if (/^(\/uninstall|!comando|\/voice )/.test(nome) || nome.includes(' ') || nome === '/clear') continue;
|
|
556
|
+
itens.push({ secao: 'Comandos', nome, desc, cmd: nome, tecla: ATALHOS_DO_LIDER[nome] || '', preencher: /\s<|<.*>/.test(desc) && /^\/(dir|worktree|rule|lock|attach|read|run|open|history|howto)$/.test(nome) });
|
|
557
|
+
}
|
|
558
|
+
return itens;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function dialogoModelo() {
|
|
562
|
+
const atual = state.mode;
|
|
563
|
+
const esforco = effort.normalizar(state.config.effort);
|
|
564
|
+
const itens = [
|
|
565
|
+
['auto', 'Auto', 'o PrimoCode escolhe entre fazer e conversar'],
|
|
566
|
+
['primocode', 'Primo Code', 'o agente que cria e edita arquivos'],
|
|
567
|
+
['chat', 'Chat', 'só conversa, sem mexer em nada'],
|
|
568
|
+
].map(([id, nome, desc]) => ({ secao: 'Modo', nome: (atual === id ? '● ' : ' ') + nome, desc, id }));
|
|
569
|
+
for (const e of ['low', 'medium', 'high', 'ultracode']) itens.push({ secao: 'Esforço', nome: (esforco === effort.normalizar(e) ? '● ' : ' ') + effort.normalizar(e), desc: e === 'ultracode' ? 'o máximo' : '', esforco: e });
|
|
570
|
+
return {
|
|
571
|
+
titulo: 'Modo e esforço', itens,
|
|
572
|
+
aoEscolher: (it, r) => {
|
|
573
|
+
if (it.id) { state.mode = it.id; state.modoEfetivo = null; cfg.saveConfig({ mode: it.id }); r.toast(`Modo: ${NOME_MODO[it.id]}`, 'ok'); }
|
|
574
|
+
else { cfg.saveConfig({ effort: it.esforco }); state.config.effort = it.esforco; r.toast(`Esforço: ${effort.normalizar(it.esforco)}`, 'ok'); }
|
|
575
|
+
refreshPrompt();
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function dialogoAgentes() {
|
|
581
|
+
let lista = [];
|
|
582
|
+
try { lista = require('../agentes-pessoais').listar({ dir: state.workDir }); } catch { lista = []; }
|
|
583
|
+
return {
|
|
584
|
+
titulo: 'Agentes',
|
|
585
|
+
vazio: 'Nenhum agente. /agentes criar <nome> | <o que faz> | <ofício>',
|
|
586
|
+
itens: lista.map((a) => ({ nome: (a.ligado ? '● ' : '○ ') + '@' + a.nome, desc: a.descricao || a.oficio || '', agente: a })),
|
|
587
|
+
aoEscolher: (it, r) => {
|
|
588
|
+
try {
|
|
589
|
+
const a = require('../agentes-pessoais').alternar(it.agente.nome, !it.agente.ligado, { dir: state.workDir });
|
|
590
|
+
r.toast(`@${a.nome} ${a.ligado ? 'ligado' : 'desligado'}`, 'ok');
|
|
591
|
+
} catch (e) { r.toast(e.message, 'erro'); }
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function dialogoSessoes() {
|
|
597
|
+
if (!conta.atual()) { if (rl) rl.toast('Entre na conta (/login) para ver as conversas salvas', 'aviso'); return null; }
|
|
598
|
+
const l = await nuvem.listarConversas({ limite: 30 });
|
|
599
|
+
if (!l.ok || !l.conversas.length) { if (rl) rl.toast(l.ok ? 'Nenhuma conversa salva ainda' : l.error, 'aviso'); return null; }
|
|
600
|
+
return {
|
|
601
|
+
titulo: 'Conversas',
|
|
602
|
+
itens: l.conversas.map((x, i) => ({ nome: x.titulo.slice(0, 44), desc: `${x.mensagens} msg`, tecla: x.atualizada ? new Date(x.atualizada).toLocaleDateString('pt-BR') : '', n: i + 1 })),
|
|
603
|
+
aoEscolher: (it, r) => { r.line = '/resume ' + it.n; r.cursor = r.line.length; r._enviar(); },
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function dialogoTema() {
|
|
608
|
+
const atual = ui.temaDoMomento();
|
|
609
|
+
return {
|
|
610
|
+
titulo: 'Tema',
|
|
611
|
+
itens: Object.entries(ui.TEMAS).map(([id, t]) => ({ nome: (atual === id ? '● ' : ' ') + t.nome, id })),
|
|
612
|
+
aoEscolher: (it, r) => { ui.definirTema(it.id); cfg.saveConfig({ tema: it.id }); r._quadroAnterior = null; r._pintar(); r.toast(`Tema: ${ui.TEMAS[it.id].nome}`, 'ok'); },
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
493
616
|
/* O Tab com a caixa vazia gira o modo, como o "tab agents" do OpenCode:
|
|
494
617
|
auto → primo code → chat → auto. A escolha fica salva, como a do /mode. */
|
|
495
618
|
function trocarModo() {
|
|
@@ -507,6 +630,7 @@ function refreshPrompt() { if (rl) rl.prompt(); }
|
|
|
507
630
|
// Enquanto uma pergunta está no ar, a próxima linha digitada é a RESPOSTA dela
|
|
508
631
|
// — não um novo comando. Sem isso o "s" de confirmação virava prompt para a IA.
|
|
509
632
|
let pendingAsk = null;
|
|
633
|
+
let permissaoPendente = null; // resolve a pergunta de permissão quando a pessoa aperta Esc
|
|
510
634
|
|
|
511
635
|
/* ── O QUE A PESSOA ADIANTA ──────────────────────────────────────────────
|
|
512
636
|
* Enquanto a abertura corre — e ela demora, porque confere o plano no
|
|
@@ -922,6 +1046,30 @@ async function prepararPasta(userText) {
|
|
|
922
1046
|
* entregar o mouse e o teclado.
|
|
923
1047
|
*/
|
|
924
1048
|
async function autorizarControle(oQueVaiFazer, { primeira, tipo } = {}) {
|
|
1049
|
+
/* NA TELA CHEIA a pergunta é um PAINEL AMARELO no meio da conversa (o de permissão do OpenCode):
|
|
1050
|
+
"enter sim · a sempre · esc não". Só o /perms perguntar tem Enter = sim, porque a pessoa pediu para
|
|
1051
|
+
ser perguntada naquele projeto. O primeiro pedido de controle do computador e o que "não tem volta"
|
|
1052
|
+
mantêm o Enter = NÃO (o padrão de quem apertou Enter sem ler não entrega o mouse). */
|
|
1053
|
+
if (rl && rl.cheia) {
|
|
1054
|
+
const enterSim = !!tipo && tipo !== 'perigo';
|
|
1055
|
+
const titulo = tipo === 'perigo' ? 'Isto não tem volta' : primeira ? 'O Primo quer usar o seu computador' : 'Permissão';
|
|
1056
|
+
const w = Math.max(30, (process.stdout.columns || 80) - 2);
|
|
1057
|
+
const teclas = enterSim
|
|
1058
|
+
? c.white('enter') + c.dim(' sim ') + c.white('a') + c.dim(' sempre ') + c.white('esc') + c.dim(' não')
|
|
1059
|
+
: c.white('1') + c.dim(' sim, agora ') + c.white('a') + c.dim(' sempre ') + c.white('enter') + c.dim('/') + c.white('esc') + c.dim(' não');
|
|
1060
|
+
console.log('');
|
|
1061
|
+
console.log(tela.painelCor([c.warn(titulo) + ' ' + c.white(oQueVaiFazer), teclas], 'warn', w).join('\n'));
|
|
1062
|
+
const dito = String(await Promise.race([
|
|
1063
|
+
ask(''),
|
|
1064
|
+
new Promise((res) => { permissaoPendente = () => res('esc'); }),
|
|
1065
|
+
])).trim().toLowerCase().replace(/[.!?,;:…]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1066
|
+
permissaoPendente = null;
|
|
1067
|
+
if (dito === 'esc') { pendingAsk = null; console.log(' ' + c.muted('Não autorizado.') + c.dim(' Ele vai resolver por outro caminho.')); return 'nao'; }
|
|
1068
|
+
if (dito === 'a' || /sess|sempre|tudo|todas|^2$/.test(dito)) { console.log(' ' + c.ok('✓ autorizado') + c.dim(' — esta sessão inteira. Fechar o PrimoCode esquece.')); return 'sessao'; }
|
|
1069
|
+
if (dito === '1' || /^(s|sim|ok|pode|agora|y|yes|claro|vai)\b/.test(dito) || (dito === '' && enterSim)) { console.log(' ' + c.ok('✓ autorizado') + c.dim(' — só esta ação')); return 'agora'; }
|
|
1070
|
+
console.log(' ' + c.muted('Não autorizado.') + c.dim(' Ele vai resolver por outro caminho.'));
|
|
1071
|
+
return 'nao';
|
|
1072
|
+
}
|
|
925
1073
|
/* O mesmo portão serve ao /permissoes (lib/permission/perigo.js): a pergunta muda de
|
|
926
1074
|
cabeçalho, as três respostas são as mesmas. */
|
|
927
1075
|
if (tipo) {
|
|
@@ -3009,7 +3157,10 @@ async function handleSlash(text) {
|
|
|
3009
3157
|
}
|
|
3010
3158
|
|
|
3011
3159
|
case 'clear': state.history = []; state.memoria = memoria.nova();
|
|
3012
|
-
state.gravador.zerar();
|
|
3160
|
+
state.gravador.zerar();
|
|
3161
|
+
ui.estadoSessao.todos = []; ui.estadoSessao.arquivos.clear();
|
|
3162
|
+
if (rl && rl.cheia) { rl.novaConversa(); rl.toast('Conversa nova', 'ok'); } else console.log(c.ok('Conversa limpa.'));
|
|
3163
|
+
break;
|
|
3013
3164
|
case 'cls': process.stdout.write('\x1Bc'); break;
|
|
3014
3165
|
|
|
3015
3166
|
/* O /status olha só o servidor. O /doctor olha tudo o que costuma
|
|
@@ -3925,6 +4076,7 @@ async function main() {
|
|
|
3925
4076
|
contexto, e sem conversa a home põe o logo e a caixa no meio da tela.
|
|
3926
4077
|
PRIMOCODE_TELA=linha (ou "tela": "linha" no config) volta ao jeito
|
|
3927
4078
|
antigo, que escreve em linha pelo terminal. */
|
|
4079
|
+
if (state.config.tema) ui.definirTema(state.config.tema);
|
|
3928
4080
|
const telaCheia = process.stdout.isTTY && process.stdout.rows >= 16
|
|
3929
4081
|
&& (process.env.PRIMOCODE_TELA || state.config.tela || 'cheia') !== 'linha';
|
|
3930
4082
|
if (!telaCheia) {
|
|
@@ -3946,7 +4098,11 @@ async function main() {
|
|
|
3946
4098
|
logoAnimado: (quadro, trabalhando) => ui.logoAnimado(state.config.mascot, quadro, trabalhando),
|
|
3947
4099
|
agora: () => (ui.agora.texto && Date.now() - ui.agora.desde < 10 * 60 * 1000 ? ui.agora.texto : ''),
|
|
3948
4100
|
versao: VERSION,
|
|
3949
|
-
logo: ui.
|
|
4101
|
+
logo: [...ui.mascot(state.config.mascot), '', ...ui.logoBloco()],
|
|
4102
|
+
logoSimples: ui.logoBloco(),
|
|
4103
|
+
plano: () => { try { const eu = conta.atual(); return eu && eu.plano ? 'Plano ' + String(eu.plano).replace(/^./, (x) => x.toUpperCase()) : ''; } catch { return ''; } },
|
|
4104
|
+
paleta: paletaDeComandos,
|
|
4105
|
+
dialogos: { modelo: dialogoModelo, agente: dialogoAgentes, sessoes: dialogoSessoes, tema: dialogoTema },
|
|
3950
4106
|
pasta: () => {
|
|
3951
4107
|
const dir = state.workDir || process.cwd();
|
|
3952
4108
|
const ramo = ramoDoGit(dir);
|
|
@@ -3954,7 +4110,8 @@ async function main() {
|
|
|
3954
4110
|
},
|
|
3955
4111
|
});
|
|
3956
4112
|
if (telaCheia && state.workDirOrigem === 'padrao') {
|
|
3957
|
-
|
|
4113
|
+
// Um aviso no canto, não uma linha na conversa: a home continua limpa.
|
|
4114
|
+
rl.toast(`pasta padrão: ${shortPath(state.workDir)} · /dir <pasta> para trabalhar numa sua`, 'aviso', 7000);
|
|
3958
4115
|
}
|
|
3959
4116
|
/* A última conversa desta pasta, em uma linha (lib/passagem.js). Uma vez por abertura, só aqui (o -p já saiu
|
|
3960
4117
|
antes) e só se a pessoa não abriu já retomando (-c / --resume). */
|
|
@@ -3987,6 +4144,7 @@ async function main() {
|
|
|
3987
4144
|
* ela é desenhada pela própria caixa (ver lib/tui/entrada.js). */
|
|
3988
4145
|
let ultimoEsc = 0;
|
|
3989
4146
|
rl.on('escape', () => {
|
|
4147
|
+
if (permissaoPendente) { permissaoPendente(); return; } // Esc na pergunta de permissão = não
|
|
3990
4148
|
if (!busy) { ultimoEsc = 0; return; }
|
|
3991
4149
|
const agora = Date.now();
|
|
3992
4150
|
if (agora - ultimoEsc < 2000) {
|
package/lib/config/config.js
CHANGED
|
@@ -343,6 +343,8 @@ function loadConfig() {
|
|
|
343
343
|
// Piloto automático e agentes em grupo (docs/estrutura.md, seção 9). Desligados por padrão.
|
|
344
344
|
piloto: file.piloto === true,
|
|
345
345
|
grupo: file.grupo === true,
|
|
346
|
+
// Preferências da tela Configurações (legenda, silêncios, efeitos, voz, formato, idioma, testes).
|
|
347
|
+
preferencias: preferenciasValidas(file.preferencias),
|
|
346
348
|
// A tela de primeira execução do app (docs/estrutura.md, 16.7): some depois de feita ou pulada.
|
|
347
349
|
inicioFeito: file.inicioFeito === true,
|
|
348
350
|
rootDir: ROOT_DIR,
|
|
@@ -350,12 +352,25 @@ function loadConfig() {
|
|
|
350
352
|
};
|
|
351
353
|
}
|
|
352
354
|
|
|
355
|
+
/* Preferências da tela Configurações: só estas chaves, cada uma com o seu tipo. O resto é descartado. */
|
|
356
|
+
const PREFERENCIAS = { legenda: 'b', silencio: 'b', sfx: 'b', testes: 'b', voz: 's', formato: 's', idioma: 's' };
|
|
357
|
+
function preferenciasValidas(o) {
|
|
358
|
+
const out = {};
|
|
359
|
+
if (!o || typeof o !== 'object') return out;
|
|
360
|
+
for (const [k, t] of Object.entries(PREFERENCIAS)) {
|
|
361
|
+
if (t === 'b' && typeof o[k] === 'boolean') out[k] = o[k];
|
|
362
|
+
else if (t === 's' && typeof o[k] === 'string' && o[k].length <= 40) out[k] = o[k];
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
353
367
|
function saveConfig(partial) {
|
|
354
368
|
ensureDir(CONFIG_DIR);
|
|
355
369
|
const cur = (() => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; } })();
|
|
356
370
|
// Só persiste o que faz sentido guardar — nada de campos derivados.
|
|
357
371
|
const { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes, team, piloto, grupo, inicioFeito } = { ...cur, ...partial };
|
|
358
|
-
const
|
|
372
|
+
const preferencias = preferenciasValidas({ ...(cur.preferencias || {}), ...(partial.preferencias || {}) });
|
|
373
|
+
const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes, team, piloto, grupo, inicioFeito, preferencias };
|
|
359
374
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
|
|
360
375
|
return next;
|
|
361
376
|
}
|
package/lib/diff.js
CHANGED
|
@@ -134,6 +134,8 @@ function desenhar(antes, depois, { prefixo = '', max = MAX_MOSTRADAS } = {}) {
|
|
|
134
134
|
const out = [];
|
|
135
135
|
let desenhadas = 0;
|
|
136
136
|
let cortou = false;
|
|
137
|
+
let larguraFaixa = 20;
|
|
138
|
+
for (const bloco of blocos) for (const o of bloco) larguraFaixa = Math.max(larguraFaixa, 4 + 1 + 2 + Math.min(o.texto.length, 120) + 1);
|
|
137
139
|
|
|
138
140
|
for (const bloco of blocos) {
|
|
139
141
|
if (desenhadas >= max) { cortou = true; break; }
|
|
@@ -143,8 +145,16 @@ function desenhar(antes, depois, { prefixo = '', max = MAX_MOSTRADAS } = {}) {
|
|
|
143
145
|
// removidas, o do antes. Sem número, "+ const x = 1" não diz onde.
|
|
144
146
|
const n = String(o.b || o.a || '').padStart(4);
|
|
145
147
|
const texto = o.texto.length > 120 ? o.texto.slice(0, 119) + '…' : o.texto;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
+
/* As linhas que entraram e saíram levam um FUNDO verde e vermelho até o fim
|
|
149
|
+
(o diff do OpenCode); as que ficaram, só o texto apagado. O fundo vale
|
|
150
|
+
a largura da linha mais comprida do bloco, para a faixa ficar reta. */
|
|
151
|
+
const faixa = (cor, fundo, sinal) => {
|
|
152
|
+
const corpo = `${n} ${sinal} ${texto}`;
|
|
153
|
+
return prefixo + fundo + c.dim(n) + fundo + ' ' + cor + sinal + ' ' + texto
|
|
154
|
+
+ ' '.repeat(Math.max(0, larguraFaixa - corpo.length)) + '\x1b[0m';
|
|
155
|
+
};
|
|
156
|
+
if (o.tipo === '+') out.push(faixa('\x1b[38;5;78m', '\x1b[48;5;22m', '+'));
|
|
157
|
+
else if (o.tipo === '-') out.push(faixa('\x1b[38;5;204m', '\x1b[48;5;52m', '-'));
|
|
148
158
|
else out.push(prefixo + c.dim(n) + ' ' + c.dim(' ' + texto));
|
|
149
159
|
desenhadas++;
|
|
150
160
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A boca do modo voz ao vivo: o texto de uma frase vira um arquivo de áudio (WAV/AIFF). NUNCA toca som: devolve o arquivo,
|
|
2
|
+
// e quem toca é a tela (que, no teste, desvia o player). Cadeia: Kokoro local em processo vivo (modelo carregado uma vez, sem
|
|
3
|
+
// provedor pago) -> `say` do macOS -> espeak-ng. Só entra o que a máquina tem.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { execFileSync, spawn } = require('child_process');
|
|
10
|
+
const { criarProcessoVivo } = require('./processo-vivo');
|
|
11
|
+
const K = require('../plugin/estudio/kokoro');
|
|
12
|
+
|
|
13
|
+
const SCRIPT_VIVO = path.join(__dirname, '..', '..', 'studio', 'voz_kokoro_vivo.py');
|
|
14
|
+
const TETO_FRASE = 500;
|
|
15
|
+
|
|
16
|
+
const achar = (nome) => { try { return execFileSync('which', [nome], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch { return null; } };
|
|
17
|
+
|
|
18
|
+
/** Frases que vão pro ouvido: sem markdown, código, caminho nem link; no máximo `max` frases curtas. */
|
|
19
|
+
function frasesParaFalar(texto, { max = 6 } = {}) {
|
|
20
|
+
const { paraFalar } = require('../plugin/voz');
|
|
21
|
+
const limpo = paraFalar(texto, { frases: max, teto: 900 });
|
|
22
|
+
const partes = limpo.match(/[^.!?…]+[.!?…]*/g) || [];
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const p of partes) { const t = p.replace(/\s+/g, ' ').trim(); if (t.length > 1) out.push(t.slice(0, TETO_FRASE)); }
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function criarFalaViva({ pasta, uv, voz = 'pf_dora', provedores } = {}) {
|
|
29
|
+
const dir = pasta || path.join(os.tmpdir(), 'primocode-voz');
|
|
30
|
+
let n = 0;
|
|
31
|
+
const arq = (ext) => { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); return path.join(dir, `frase-${Date.now()}-${++n}.${ext}`); };
|
|
32
|
+
|
|
33
|
+
let kokoro = null;
|
|
34
|
+
const kokoroVivo = () => {
|
|
35
|
+
if (kokoro) return kokoro;
|
|
36
|
+
const u = uv || achar('uv') || (fs.existsSync(path.join(os.homedir(), '.local', 'bin', 'uv')) ? path.join(os.homedir(), '.local', 'bin', 'uv') : null);
|
|
37
|
+
const esp = K.acharEspeak();
|
|
38
|
+
if (!u || !esp || !fs.existsSync(SCRIPT_VIVO)) return null;
|
|
39
|
+
const args = ['run', '--quiet', '--python', '3.11'];
|
|
40
|
+
for (const d of K.DEPS) args.push('--with', d);
|
|
41
|
+
args.push('python', SCRIPT_VIVO);
|
|
42
|
+
kokoro = criarProcessoVivo({ cmd: u, args, env: { ...process.env, ESPEAK_DATA_PATH: esp.dados, PHONEMIZER_ESPEAK_LIBRARY: esp.lib }, tempoPronto: 15 * 60000, tempoPedido: 60000 });
|
|
43
|
+
return kokoro;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const cadeia = provedores || [
|
|
47
|
+
{ nome: 'kokoro', disponivel: () => !!kokoroVivo() && process.env.PRIMOCODE_VOZ !== 'say',
|
|
48
|
+
async falar(texto) { const o = arq('wav'); const r = await kokoroVivo().pedir({ texto, saida: o, voz }); return r.ok ? { ok: true, arquivo: o, mime: 'audio/wav' } : { ok: false, error: r.error }; } },
|
|
49
|
+
{ nome: 'say', disponivel: () => process.platform === 'darwin' && !!achar('say'),
|
|
50
|
+
falar: (texto) => new Promise((res) => { const o = arq('aiff'); const p = spawn('say', ['-v', 'Luciana', '-o', o, texto]); p.on('error', (e) => res({ ok: false, error: e.message })); p.on('close', (c) => res(c === 0 && fs.existsSync(o) ? { ok: true, arquivo: o, mime: 'audio/aiff' } : { ok: false, error: 'o say não gerou o áudio' })); }) },
|
|
51
|
+
{ nome: 'espeak-ng', disponivel: () => !!achar('espeak-ng'),
|
|
52
|
+
falar: (texto) => new Promise((res) => { const o = arq('wav'); const p = spawn('espeak-ng', ['-v', 'pt-br', '-w', o, texto]); p.on('error', (e) => res({ ok: false, error: e.message })); p.on('close', (c) => res(c === 0 && fs.existsSync(o) ? { ok: true, arquivo: o, mime: 'audio/wav' } : { ok: false, error: 'o espeak-ng não gerou o áudio' })); }) },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
/** Sobe o Kokoro em segundo plano (modelo carregado antes da primeira frase). */
|
|
57
|
+
aquecer() { const k = cadeia[0].nome === 'kokoro' && cadeia[0].disponivel() ? kokoroVivo() : null; return k ? k.aquecer() : Promise.resolve({ ok: false }); },
|
|
58
|
+
disponivel() { return cadeia.find((p) => p.disponivel()) ? { ok: true, provedor: cadeia.find((p) => p.disponivel()).nome } : { ok: false, motivo: 'sem voz local: instale o uv e o espeak-ng (brew install uv espeak-ng) e rode `primocode doutor`' }; },
|
|
59
|
+
/** Uma frase vira áudio. Devolve {ok, arquivo, mime, provedor, ms} (nunca rejeita). */
|
|
60
|
+
async falar(texto) {
|
|
61
|
+
const t0 = Date.now();
|
|
62
|
+
const frase = String(texto || '').trim().slice(0, TETO_FRASE);
|
|
63
|
+
if (!frase) return { ok: false, error: 'falta texto' };
|
|
64
|
+
let ultimo = 'sem voz local: instale o uv e o espeak-ng (brew install uv espeak-ng) e rode `primocode doutor`';
|
|
65
|
+
for (const p of cadeia) {
|
|
66
|
+
if (!p.disponivel()) continue;
|
|
67
|
+
const r = await p.falar(K.pronuncia(frase));
|
|
68
|
+
if (r.ok) return { ...r, provedor: p.nome, ms: Date.now() - t0 };
|
|
69
|
+
ultimo = r.error || ultimo;
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, error: ultimo };
|
|
72
|
+
},
|
|
73
|
+
parar() { if (kokoro) { kokoro.parar(); kokoro = null; } },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { criarFalaViva, frasesParaFalar };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Um processo filho que fica VIVO e conversa por linhas de JSON (uma linha entra, uma linha sai, casadas pelo `id`).
|
|
2
|
+
// Serve ao modo voz ao vivo: o Whisper e o Kokoro carregam o modelo uma vez e respondem de pé, sem subir um processo por trecho.
|
|
3
|
+
// Nunca toca som. Sobe na primeira chamada; `pronto` espera a linha {"pronto":true}; se o filho cai, o próximo pedido sobe outro.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const { spawn } = require('child_process');
|
|
7
|
+
|
|
8
|
+
function criarProcessoVivo({ cmd, args = [], env, cwd, tempoPronto = 180000, tempoPedido = 120000 } = {}) {
|
|
9
|
+
let filho = null, pronto = null, buf = '', id = 0;
|
|
10
|
+
const pend = new Map();
|
|
11
|
+
|
|
12
|
+
function soltarTudo(erro) { for (const p of pend.values()) { clearTimeout(p.t); p.res({ ok: false, error: erro }); } pend.clear(); }
|
|
13
|
+
|
|
14
|
+
function subir() {
|
|
15
|
+
if (filho && pronto) return pronto;
|
|
16
|
+
buf = '';
|
|
17
|
+
filho = spawn(cmd, args, { env: env || process.env, cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
18
|
+
const meu = filho;
|
|
19
|
+
let erro = '';
|
|
20
|
+
meu.stderr.on('data', (d) => { erro = (erro + d).slice(-600); });
|
|
21
|
+
pronto = new Promise((resolve) => {
|
|
22
|
+
const t = setTimeout(() => resolve({ ok: false, error: 'o processo de voz demorou demais para ficar pronto' }), tempoPronto);
|
|
23
|
+
meu.stdout.on('data', (d) => {
|
|
24
|
+
buf += d;
|
|
25
|
+
let i;
|
|
26
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
27
|
+
const linha = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
|
|
28
|
+
if (!linha) continue;
|
|
29
|
+
let m; try { m = JSON.parse(linha); } catch { continue; }
|
|
30
|
+
if (m.pronto) { clearTimeout(t); resolve({ ok: true }); continue; }
|
|
31
|
+
if (m.id === undefined || m.id === null) { if (m.ok === false) { clearTimeout(t); resolve({ ok: false, error: m.error || 'o processo de voz não subiu' }); } continue; }
|
|
32
|
+
const p = pend.get(m.id);
|
|
33
|
+
if (p) { pend.delete(m.id); clearTimeout(p.t); p.res(m); }
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
meu.on('error', (e) => { clearTimeout(t); resolve({ ok: false, error: e.message }); });
|
|
37
|
+
meu.on('close', () => {
|
|
38
|
+
clearTimeout(t);
|
|
39
|
+
const era = filho === meu;
|
|
40
|
+
if (era) { filho = null; pronto = null; }
|
|
41
|
+
resolve({ ok: false, error: erro.trim().split('\n').pop() || 'o processo de voz saiu' });
|
|
42
|
+
soltarTudo('o processo de voz saiu');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
return pronto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
/** Sobe (se preciso) e espera ficar pronto. Devolve {ok} ou {ok:false,error}. */
|
|
50
|
+
async aquecer() { const r = await subir(); if (!r.ok) { this.parar(); } return r; },
|
|
51
|
+
async pedir(dados) {
|
|
52
|
+
const r = await subir();
|
|
53
|
+
if (!r.ok) { this.parar(); return r; }
|
|
54
|
+
return new Promise((res) => {
|
|
55
|
+
const n = ++id;
|
|
56
|
+
const t = setTimeout(() => { pend.delete(n); res({ ok: false, error: 'a voz demorou demais' }); }, tempoPedido);
|
|
57
|
+
pend.set(n, { res, t });
|
|
58
|
+
try { filho.stdin.write(`${JSON.stringify({ ...dados, id: n })}\n`); } catch (e) { pend.delete(n); clearTimeout(t); res({ ok: false, error: e.message }); }
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
parar() { const f = filho; filho = null; pronto = null; if (f) { try { f.kill('SIGTERM'); } catch { /* já saiu */ } } soltarTudo('parado'); },
|
|
62
|
+
vivo: () => !!filho,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { criarProcessoVivo };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Transcrição da fala do app (bug 3, 02/11): o renderer grava com MediaRecorder e manda o WAV; aqui ele vira texto
|
|
2
|
+
// com o Whisper local que o produto já tem (voz/primo_voz/transcrever.py, no voz-venv). Sem provedor pago, sem rede.
|
|
3
|
+
// Nunca toca som: só lê um arquivo.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawn, execFileSync } = require('child_process');
|
|
10
|
+
const { criarProcessoVivo } = require('./processo-vivo');
|
|
11
|
+
|
|
12
|
+
const PASTA_VOZ = path.join(__dirname, '..', '..', 'voz');
|
|
13
|
+
const TETO_BYTES = 11 * 1024 * 1024; // ~5 min de WAV 16 kHz mono (em base64 cabe no teto de 16 MB do WebSocket)
|
|
14
|
+
const TEMPO_MS = 120000;
|
|
15
|
+
|
|
16
|
+
/** O que falta para transcrever aqui, ou null se está tudo pronto. `py` vem de lib/plugin/voz.js (o venv do Primo ganha). */
|
|
17
|
+
function faltaAlgo(py) {
|
|
18
|
+
if (!py) return { error: 'o modo voz precisa do Python 3, e não achei nenhum nesta máquina.', comoResolver: 'instale o Python 3 e rode `primocode doutor`' };
|
|
19
|
+
try { execFileSync(py, ['-c', 'import faster_whisper'], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 20000 }); return null; } catch {
|
|
20
|
+
return { error: 'falta o Whisper local (faster-whisper), que transforma a sua fala em texto.', comoResolver: 'rode `primocode doutor` (ou abra o /voice no terminal uma vez, que ele instala)' };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `transcrever(wav, { python })` → { ok, texto } | { ok:false, error, comoResolver? }. Nunca rejeita. */
|
|
25
|
+
function transcrever(wav, { python: py, idioma = 'pt', tempoMs = TEMPO_MS } = {}) {
|
|
26
|
+
py = py || require('../plugin/voz').python();
|
|
27
|
+
const falta = faltaAlgo(py);
|
|
28
|
+
if (falta) return Promise.resolve({ ok: false, ...falta });
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const p = spawn(py, ['-m', 'primo_voz.transcrever', 'arquivo', wav, idioma], { cwd: PASTA_VOZ, env: { ...process.env, PYTHONPATH: PASTA_VOZ } });
|
|
31
|
+
let fora = '', erro = '';
|
|
32
|
+
const t = setTimeout(() => { try { p.kill('SIGKILL'); } catch { /* já saiu */ } resolve({ ok: false, error: 'a transcrição demorou demais' }); }, tempoMs);
|
|
33
|
+
p.stdout.on('data', (d) => { fora += d; });
|
|
34
|
+
p.stderr.on('data', (d) => { erro += d; });
|
|
35
|
+
p.on('error', (e) => { clearTimeout(t); resolve({ ok: false, error: e.message }); });
|
|
36
|
+
p.on('close', () => {
|
|
37
|
+
clearTimeout(t);
|
|
38
|
+
try { const r = JSON.parse(fora.trim().split('\n').pop()); resolve(r.ok ? { ok: true, texto: String(r.texto || '') } : { ok: false, error: r.error || 'não consegui transcrever' }); }
|
|
39
|
+
catch { resolve({ ok: false, error: erro.trim().split('\n').pop() || 'o transcritor não respondeu' }); }
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* O Whisper VIVO (modo voz ao vivo): um processo Python que carrega o modelo uma vez e transcreve arquivo por arquivo. */
|
|
45
|
+
let vivo = null;
|
|
46
|
+
function whisperVivo(py) {
|
|
47
|
+
if (!vivo) vivo = criarProcessoVivo({ cmd: py, args: ['-m', 'primo_voz.transcrever', 'servir', 'pt'], cwd: PASTA_VOZ, env: { ...process.env, PYTHONPATH: PASTA_VOZ } });
|
|
48
|
+
return vivo;
|
|
49
|
+
}
|
|
50
|
+
/** Sobe o Whisper em segundo plano (o modelo carrega antes da primeira fala). Nunca rejeita. */
|
|
51
|
+
function aquecer({ python: py } = {}) {
|
|
52
|
+
py = py || require('../plugin/voz').python();
|
|
53
|
+
if (faltaAlgo(py)) return Promise.resolve({ ok: false });
|
|
54
|
+
return whisperVivo(py).aquecer();
|
|
55
|
+
}
|
|
56
|
+
function parar() { if (vivo) { vivo.parar(); vivo = null; } }
|
|
57
|
+
/** Igual a `transcrever`, mas no processo vivo; se ele não sobe, cai no processo avulso. */
|
|
58
|
+
async function transcreverVivo(wav, opts = {}) {
|
|
59
|
+
const py = opts.python || require('../plugin/voz').python();
|
|
60
|
+
const falta = faltaAlgo(py);
|
|
61
|
+
if (falta) return { ok: false, ...falta };
|
|
62
|
+
const r = await whisperVivo(py).pedir({ arquivo: wav });
|
|
63
|
+
if (r.ok) return { ok: true, texto: String(r.texto || '') };
|
|
64
|
+
return transcrever(wav, { python: py });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Grava o áudio (base64, WAV) num arquivo temporário, transcreve e apaga. `transcritor` troca o Whisper (teste). */
|
|
68
|
+
async function transcreverBase64(audio, { pasta, transcritor = transcreverVivo } = {}) {
|
|
69
|
+
if (typeof audio !== 'string' || !audio) return { ok: false, error: 'falta o áudio' };
|
|
70
|
+
const bytes = Buffer.from(audio, 'base64');
|
|
71
|
+
if (!bytes.length) return { ok: false, error: 'áudio vazio' };
|
|
72
|
+
if (bytes.length > TETO_BYTES) return { ok: false, error: 'áudio grande demais (máximo ~5 minutos)' };
|
|
73
|
+
if (bytes.subarray(0, 4).toString('latin1') !== 'RIFF') return { ok: false, error: 'o áudio precisa ser WAV' };
|
|
74
|
+
const dir = pasta || path.join(os.tmpdir(), 'primocode-voz');
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
76
|
+
const arq = path.join(dir, `fala-${Date.now()}-${process.pid}.wav`);
|
|
77
|
+
fs.writeFileSync(arq, bytes, { mode: 0o600 });
|
|
78
|
+
try { return await transcritor(arq); } finally { try { fs.rmSync(arq, { force: true }); } catch { /* sem arquivo */ } }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { transcrever, transcreverVivo, transcreverBase64, aquecer, parar, faltaAlgo, TETO_BYTES };
|
|
@@ -103,7 +103,7 @@ function acharNavegador(env = process.env, existe = require('fs').existsSync) {
|
|
|
103
103
|
function htmlParaPdf(htmlPath, pdfPath, { navegador = acharNavegador() } = {}) {
|
|
104
104
|
if (!navegador) return { ok: false, erro: 'Chrome/Chromium não encontrado (defina PRIMO_CHROME)' };
|
|
105
105
|
const { spawnSync } = require('child_process');
|
|
106
|
-
const r = spawnSync(navegador, ['--headless=new', '--disable-gpu', '--no-pdf-header-footer',
|
|
106
|
+
const r = spawnSync(navegador, ['--headless=new', '--use-mock-keychain', '--password-store=basic', '--disable-gpu', '--no-pdf-header-footer',
|
|
107
107
|
`--print-to-pdf=${pdfPath}`, 'file://' + htmlPath], { encoding: 'utf8', timeout: 90000 });
|
|
108
108
|
return require('fs').existsSync(pdfPath) ? { ok: true } : { ok: false, erro: (r.stderr || '').slice(-300) || 'PDF não saiu' };
|
|
109
109
|
}
|
|
@@ -180,7 +180,7 @@ function htmlParaPngs(htmlPath, baseSemExt, n, w, h, { escala = 1, navegador = a
|
|
|
180
180
|
const tmp = `${baseSemExt}.tmp-${i}.html`;
|
|
181
181
|
const alvo = `${baseSemExt}-${String(i).padStart(2, '0')}.png`;
|
|
182
182
|
fs.writeFileSync(tmp, html.replace('</head>', `<style>section:not(:nth-of-type(${i})){display:none!important}html,body{height:${h}px;overflow:hidden}</style></head>`));
|
|
183
|
-
spawnSync(navegador, ['--headless=new', '--disable-gpu', '--hide-scrollbars', `--force-device-scale-factor=${escala}`,
|
|
183
|
+
spawnSync(navegador, ['--headless=new', '--use-mock-keychain', '--password-store=basic', '--disable-gpu', '--hide-scrollbars', `--force-device-scale-factor=${escala}`,
|
|
184
184
|
`--window-size=${w},${h}`, `--screenshot=${alvo}`, 'file://' + tmp], { encoding: 'utf8', timeout: 90000 });
|
|
185
185
|
try { fs.unlinkSync(tmp); } catch {}
|
|
186
186
|
if (fs.existsSync(alvo)) arquivos.push(alvo); else erro = `PNG ${i} não saiu`;
|
|
@@ -519,6 +519,9 @@ function abrirTela({ home, abrir, timeoutMs = 10 * 60 * 1000, porta = 0, inicio
|
|
|
519
519
|
const token = crypto.randomBytes(16).toString('hex');
|
|
520
520
|
let html;
|
|
521
521
|
try { html = fs.readFileSync(path.join(__dirname, 'gosto', 'tela.html'), 'utf8'); } catch { return resolve({ ok: false, motivo: 'sem_tela' }); }
|
|
522
|
+
// No app do PrimoCode a tela abre num quadro (iframe) e o "Finalizar" (window.close) não faz nada ali: avisa a janela de fora
|
|
523
|
+
// para fechar o quadro. Entra na hora de servir, porque o gosto/tela.html é guardado sem mudança (test/kits-tela.test.js confere o sha1).
|
|
524
|
+
html = html.replace('</body>', "<script>document.addEventListener('click',function(e){var b=e.target.closest&&e.target.closest('#finalizar');if(b&&window.parent!==window)window.parent.postMessage('primocode:tela-fim','*');},true);</script></body>");
|
|
522
525
|
// os vídeos que já estão no gosto: quem abre o /referencias de novo vê o que já trouxe (só o nome)
|
|
523
526
|
const jaTem = ((ler(home) || {}).referencias || []).map((r) => String(r.nome || 'vídeo').slice(0, 80));
|
|
524
527
|
const dados = JSON.stringify({ ESTILOS, GOSTEI, NAO_QUERO, INICIO: inicio === 'referencias' ? 'referencias' : 'carrossel', JA_TEM: jaTem }).replace(/</g, '\\u003c');
|
|
@@ -380,9 +380,11 @@ function mandar(rota, corpo) {
|
|
|
380
380
|
}
|
|
381
381
|
function erro(msg) { const e = $('#erro'); e.hidden = !msg; e.textContent = msg || ''; }
|
|
382
382
|
|
|
383
|
+
/* No app do PrimoCode a tela abre dentro de um quadro (iframe): avisa a janela de fora que acabou, pra ela fechar o quadro. */
|
|
384
|
+
function avisarApp() { try { if (window.parent !== window) setTimeout(() => window.parent.postMessage('primocode:tela-fim', '*'), 1200); } catch { /* aberta solta no navegador */ } }
|
|
383
385
|
function mostrar(n) {
|
|
384
386
|
[1, 4].forEach((i) => { $('#passo' + i).hidden = i !== n; });
|
|
385
|
-
if (n === 4) { $('#titulo').textContent = 'Pronto. O PrimoCode já sabe o estilo.'; $('#sub').hidden = true; }
|
|
387
|
+
if (n === 4) { $('#titulo').textContent = 'Pronto. O PrimoCode já sabe o estilo.'; $('#sub').hidden = true; avisarApp(); }
|
|
386
388
|
window.scrollTo({ top: 0, behavior: parar ? 'auto' : 'smooth' });
|
|
387
389
|
}
|
|
388
390
|
|