diff --git a/tests/test_painel_web_browser.py b/tests/test_painel_web_browser.py index 0e4520d..f7b24df 100644 --- a/tests/test_painel_web_browser.py +++ b/tests/test_painel_web_browser.py @@ -17,7 +17,10 @@ spec = importlib.util.spec_from_file_location('kiosk_web', ROOT / 'kiosk-server. kiosk = importlib.util.module_from_spec(spec) spec.loader.exec_module(kiosk) from braco import Braco +from cerebro import Cerebro +from conversa import Conversa from servos import ControleServos +from treinamento import Treinamento TOKEN = 'token-de-teste-123' @@ -38,6 +41,10 @@ def main(capturas=None): server.controle = kiosk.ControlePC(tmp / 'app.json') server.servos = ControleServos(tmp / 'servos.json', HW()) server.braco = Braco(tmp / 'braco.json', server.servos) + server.cerebro = Cerebro(tmp / 'ia.json', server.controle, kiosk.enfileirar) + server.conversa = Conversa(tmp / 'memoria.db', server.cerebro) + server.treinamento = Treinamento(tmp / 'treinamento.db', iniciar=False) + server.conversa.treinamento = server.treinamento kiosk.ligar_eventos(server) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -109,6 +116,40 @@ def testar(base, server, capturas): page.get_by_label('Buscar ajuste').fill('') page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True) + # 6b. conversa: pergunta de hora é respondida pelo relógio (sem IA) e chega por SSE + page.get_by_role('link', name='Conversa').first.click() + page.get_by_label('Mensagem').fill('Que horas são?') + page.get_by_role('button', name='Enviar').click() + expect(page.get_by_text('no horário de São Paulo')).to_be_visible(timeout=5000) + page.get_by_label('Nova lembrança').fill('Meu nome é Júlio') + page.get_by_role('button', name='Adicionar').click() + expect(page.get_by_text('Meu nome é Júlio')).to_be_visible(timeout=5000) + page.get_by_role('tab', name='Rotina e preferências').click() + page.get_by_label('Nova rotina').fill('Treino às 7h') + page.get_by_role('button', name='Adicionar').click() + expect(page.get_by_text('Treino às 7h')).to_be_visible(timeout=5000) + if capturas: + page.screenshot(path=str(Path(capturas) / 'conversa.png'), full_page=True) + + # 6c. reações: criar emoji, mostrar no celular, excluir e desfazer pelo toast + page.get_by_role('link', name='Reações').first.click() + page.get_by_role('button', name='Novo emoji').click() + page.get_by_label('Nome').fill('Ideia brilhante') + page.get_by_label('Quando usar').fill('Quando tiver uma ideia') + page.get_by_role('radio', name='🚀').click() + page.get_by_role('button', name='Salvar').click() + cartao = page.locator('li.reacao', has_text='Ideia brilhante') + expect(cartao).to_be_visible(timeout=5000) + cartao.get_by_role('button', name='Mostrar').click() + page.wait_for_timeout(400) + assert server.treinamento.expressao['receita']['nome'] == 'Ideia brilhante' + cartao.get_by_role('button', name='Excluir').click() + expect(cartao).to_have_count(0, timeout=5000) + page.get_by_role('button', name='Desfazer').last.click() + expect(page.locator('li.reacao', has_text='Ideia brilhante')).to_be_visible(timeout=5000) + if capturas: + page.screenshot(path=str(Path(capturas) / 'reacoes.png'), full_page=True) + # 7. celular: barra inferior e nada de rolagem horizontal page.set_viewport_size({'width': 400, 'height': 860}) page.get_by_role('link', name='Ao vivo').click() @@ -129,7 +170,7 @@ def testar(base, server, capturas): assert not erros, erros browser.close() - print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, celular 400px, tema claro') + print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, conversa, memória, reações com desfazer, celular 400px, tema') if __name__ == '__main__': diff --git a/web/src/App.svelte b/web/src/App.svelte index 25c5563..8ebbd2a 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -169,7 +169,7 @@ - {#if nav.atual !== 'ao-vivo'} + {#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'} {/if} diff --git a/web/src/lib/componentes/Abas.svelte b/web/src/lib/componentes/Abas.svelte new file mode 100644 index 0000000..4473fab --- /dev/null +++ b/web/src/lib/componentes/Abas.svelte @@ -0,0 +1,64 @@ + + +
+ {#each abas as aba, i (aba.id)} + + {/each} +
+ + diff --git a/web/src/lib/componentes/Miniatura.svelte b/web/src/lib/componentes/Miniatura.svelte new file mode 100644 index 0000000..98a00e2 --- /dev/null +++ b/web/src/lib/componentes/Miniatura.svelte @@ -0,0 +1,56 @@ + + + + + diff --git a/web/src/lib/componentes/Modal.svelte b/web/src/lib/componentes/Modal.svelte new file mode 100644 index 0000000..744dc07 --- /dev/null +++ b/web/src/lib/componentes/Modal.svelte @@ -0,0 +1,56 @@ + + + (aberto = false)} + onclick={(e) => e.target === dialogo && (aberto = false)} + aria-label={titulo} +> +
+
+

{titulo}

+ +
+ {#if aberto}{@render children()}{/if} +
+
+ + diff --git a/web/src/lib/tema.css b/web/src/lib/tema.css index 0a0eac6..25bd428 100644 --- a/web/src/lib/tema.css +++ b/web/src/lib/tema.css @@ -101,7 +101,7 @@ img, video, canvas, iframe { max-width: 100%; } .btn.fantasma:hover:not(:disabled) { background: var(--superficie-2); } .btn.icone { padding: 0; width: 40px; } .btn.pequeno { min-height: 32px; padding: 0 var(--e-3); font-size: var(--t-xs); } -.btn.pequeno.icone { width: 32px; } +.btn.pequeno.icone { width: 32px; padding: 0; } /* ---------- formulários ---------- */ .campo { display: grid; gap: var(--e-1); } diff --git a/web/src/lib/treinamento.svelte.ts b/web/src/lib/treinamento.svelte.ts new file mode 100644 index 0000000..166a55f --- /dev/null +++ b/web/src/lib/treinamento.svelte.ts @@ -0,0 +1,73 @@ +/** + * Biblioteca de reações, rotinas e diário (/treinamento/*). Sem polling: recarrega ao abrir a + * tela, depois de cada ação e quando o servidor avisa (SSE 'treinamento') que terminou de criar. + */ +import { pedir, postar } from './api'; +import { avisos } from './avisos.svelte'; +import { ouvir } from './estado.svelte'; + +export interface ReacaoBiblioteca { + id: number; + horario: number; + receita: any; + feedback: -1 | 0 | 1; + ativa: boolean; +} + +export interface StatusTreinamento { + ocupado: boolean; + erro: string; + automatico: boolean; + rotina: { id: number; texto: string }[]; + diario: { id: number; horario: number; papel: string; texto: string }[]; + resumos: { id: number; horario: number; texto: string }[]; + emojis: string[]; + desfazer: [string] | null; + reacoes: ReacaoBiblioteca[]; + prompt_orientacao: string; + ultimo_prompt_figurinha: string; +} + +class Treinamento { + status = $state(null); + carregando = $state(false); + + async carregar() { + this.carregando = true; + try { + this.status = await pedir('/treinamento/status'); + } catch (e) { + avisos.erro(e); + } finally { + this.carregando = false; + } + } + + /** Executa uma ação e recarrega; devolve true se deu certo (erro vira toast). */ + async acao(rota: string, dados: object = {}, sucesso?: string) { + try { + await postar('/treinamento/' + rota, dados); + if (sucesso) avisos.ok(sucesso); + await this.carregar(); + return true; + } catch (e) { + avisos.erro(e); + return false; + } + } +} + +export const treinamento = new Treinamento(); + +let estavaOcupado = false; +ouvir('treinamento', (d: { ocupado: boolean; erro: string }) => { + if (treinamento.status) treinamento.status = { ...treinamento.status, ocupado: d.ocupado, erro: d.erro }; + if (estavaOcupado && !d.ocupado) { + if (d.erro) avisos.mostrar('Gemini: ' + d.erro, 'erro'); + else avisos.ok('Novas reações prontas.'); + treinamento.carregar(); + } + estavaOcupado = d.ocupado; +}); + +export const SIMBOLO_EMOJI: Record = { ideia: '💡', cafe: '☕', musica: '🎵', coracao: '❤️', estrela: '⭐' }; diff --git a/web/src/telas/Conversa.svelte b/web/src/telas/Conversa.svelte index b3fecfb..e7d3518 100644 --- a/web/src/telas/Conversa.svelte +++ b/web/src/telas/Conversa.svelte @@ -1,7 +1,403 @@ - -

Enquanto isso, use a tela antiga: abrir Conversa (versão anterior).

-
+
+ + {#snippet acoes()} + {#if conversa} + {conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'} · {conversa.modelo} + trocar + {/if} + {/snippet} + +
+ {#if !conversa?.historico.length} +
+

Converse com o Carinha. Ele responde aqui e reage com o rosto no celular.

+
+ {#each SUGESTOES as s (s)} + + {/each} +
+
+ {/if} + {#each conversa?.historico ?? [] as m, i (i)} +
+

{m.content}

+ {#if m.role === 'assistant'} + + {/if} +
+ {/each} + {#if conversa?.ocupado} +
+ {/if} + {#if conversa?.ultima && !conversa.ocupado && GESTOS_INFO[conversa.ultima.gesto]} + + {/if} +
+ + {#if conversa?.erro}

{conversa.erro}

{/if} + +
{ + e.preventDefault(); + enviar(); + }} + > + + + +
+
+ { + lerEmVoz = v; + try { + localStorage.setItem('carinha_voz', v ? '1' : '0'); + } catch { + /* ok */ + } + }} + /> +
+
+ + + +
+ + +
+
    + {#each itensMemoria as item (item.id)} +
  • + {item.texto} + +
  • + {:else} +
  • Nada guardado ainda.
  • + {/each} +
+
+ {#if confirmarLimpar} +

Apagar conversa, lembranças e diário? Rotinas e reações ficam.

+
+ + +
+ {:else} + + {/if} +
+
+
+ + diff --git a/web/src/telas/Reacoes.svelte b/web/src/telas/Reacoes.svelte index 8dcd849..27b06a0 100644 --- a/web/src/telas/Reacoes.svelte +++ b/web/src/telas/Reacoes.svelte @@ -1,7 +1,449 @@ - -

Enquanto isso, use a tela antiga: abrir Reações (versão anterior).

-
+
+
+ + + {#if aba === 'biblioteca'} +
+ + + + + +
+ + {#if !st} +

Carregando biblioteca…

+ {:else} +
    + {#each reacoes as r (r.id)} +
  • + +
    + + + + + + + + +
    +
  • + {:else} +
  • {busca ? 'Nenhuma reação encontrada.' : 'Crie um emoji ou peça um desenho ao Gemini.'}
  • + {/each} +
+ {/if} + {:else if aba === 'criar'} +
+ +
+ + +
+ {#if st?.ultimo_prompt_figurinha} +
+ Último pedido enviado +
{st.ultimo_prompt_figurinha}
+
+ {/if} +
+ + + treinamento.acao('config', { automatico: v })} + /> + {#if st?.ocupado}Gemini trabalhando…{/if} + {#if st?.erro}

{st.erro}

{/if} +
+
+ {:else if aba === 'gestos'} + + + + {:else} +
+ + {#each st?.resumos ?? [] as r (r.id)} +

{r.texto}

+ {:else} +

Os resumos aparecem quando o Gemini gera reações.

+ {/each} +
+ + {#snippet acoes()} + {#if confirmarDiario} + + + {:else} + + {/if} + {/snippet} +
+ {#each st?.diario ?? [] as m (m.id)} +
+ +

{m.texto}

+
+ {:else} +

Vazio.

+ {/each} +
+
+
+ {/if} +
+ + +
+ + +
+ + + {#if !editando?.receita.figurinha} +
+ Emoji +
+ {#each st?.emojis ?? [] as e (e)} + + {/each} +
+
+ + {/if} +
+ + +
+
+
+ +