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 @@
+
+
+
+ {#if receita?.figurinha?.formas}
+
+ {:else if receita?.emoji}
+
{receita.emoji.caractere}
+ {:else}
+
+ {#if simbolo}
{simbolo}{/if}
+ {/if}
+
+
+
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 @@
+
+
+
+
+
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}
+
+
+
+ {
+ 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}
+
+
+
+
+
+
+
+
+
+