feat(web): migra Conversa e Reacoes para o painel novo
- Conversa: chat empurrado por SSE, indicador de "pensando", gesto da resposta com repetir, voz com aviso claro quando o contexto nao e seguro e leitura em voz alta so de respostas novas - Memoria unificada: lembrancas da conversa + rotina/preferencias em abas; apagar tudo com confirmacao inline (sem confirm nativo) - Reacoes: biblioteca com miniaturas estaticas (sem iframe por cartao), busca, mostrar no celular, feedback, editar em <dialog>, duplicar, ativar e excluir com Desfazer no toast; criar com IA; diario; grade de gestos com estrela para favoritos; uma unica previa lateral - treinamento recarrega por evento SSE ao terminar, sem polling - corrige icones encolhidos em botoes .pequeno.icone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,10 @@ spec = importlib.util.spec_from_file_location('kiosk_web', ROOT / 'kiosk-server.
|
|||||||
kiosk = importlib.util.module_from_spec(spec)
|
kiosk = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(kiosk)
|
spec.loader.exec_module(kiosk)
|
||||||
from braco import Braco
|
from braco import Braco
|
||||||
|
from cerebro import Cerebro
|
||||||
|
from conversa import Conversa
|
||||||
from servos import ControleServos
|
from servos import ControleServos
|
||||||
|
from treinamento import Treinamento
|
||||||
|
|
||||||
TOKEN = 'token-de-teste-123'
|
TOKEN = 'token-de-teste-123'
|
||||||
|
|
||||||
@@ -38,6 +41,10 @@ def main(capturas=None):
|
|||||||
server.controle = kiosk.ControlePC(tmp / 'app.json')
|
server.controle = kiosk.ControlePC(tmp / 'app.json')
|
||||||
server.servos = ControleServos(tmp / 'servos.json', HW())
|
server.servos = ControleServos(tmp / 'servos.json', HW())
|
||||||
server.braco = Braco(tmp / 'braco.json', server.servos)
|
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)
|
kiosk.ligar_eventos(server)
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -109,6 +116,40 @@ def testar(base, server, capturas):
|
|||||||
page.get_by_label('Buscar ajuste').fill('')
|
page.get_by_label('Buscar ajuste').fill('')
|
||||||
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
|
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
|
# 7. celular: barra inferior e nada de rolagem horizontal
|
||||||
page.set_viewport_size({'width': 400, 'height': 860})
|
page.set_viewport_size({'width': 400, 'height': 860})
|
||||||
page.get_by_role('link', name='Ao vivo').click()
|
page.get_by_role('link', name='Ao vivo').click()
|
||||||
@@ -129,7 +170,7 @@ def testar(base, server, capturas):
|
|||||||
|
|
||||||
assert not erros, erros
|
assert not erros, erros
|
||||||
browser.close()
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+1
-1
@@ -169,7 +169,7 @@
|
|||||||
<Tela />
|
<Tela />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{#if nav.atual !== 'ao-vivo'}
|
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'}
|
||||||
<MiniRosto />
|
<MiniRosto />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts" generics="T extends string">
|
||||||
|
let { abas, ativa = $bindable(), rotulo }: { abas: { id: T; titulo: string; contador?: number }[]; ativa: T; rotulo: string } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
function tecla(e: KeyboardEvent, i: number) {
|
||||||
|
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
||||||
|
if (!d) return;
|
||||||
|
const prox = abas[(i + d + abas.length) % abas.length];
|
||||||
|
ativa = prox.id;
|
||||||
|
(e.currentTarget as HTMLElement).parentElement?.querySelector<HTMLElement>(`[data-aba="${prox.id}"]`)?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="abas" role="tablist" aria-label={rotulo}>
|
||||||
|
{#each abas as aba, i (aba.id)}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
data-aba={aba.id}
|
||||||
|
aria-selected={ativa === aba.id}
|
||||||
|
tabindex={ativa === aba.id ? 0 : -1}
|
||||||
|
onclick={() => (ativa = aba.id)}
|
||||||
|
onkeydown={(e) => tecla(e, i)}
|
||||||
|
>
|
||||||
|
{aba.titulo}
|
||||||
|
{#if aba.contador !== undefined}<span class="contador">{aba.contador}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.abas {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--e-1);
|
||||||
|
border-bottom: 1px solid var(--borda);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: var(--e-2) var(--e-3);
|
||||||
|
min-height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-3);
|
||||||
|
font: 500 var(--t-sm) var(--fonte);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
button[aria-selected='true'] {
|
||||||
|
color: var(--texto);
|
||||||
|
border-bottom-color: var(--destaque);
|
||||||
|
}
|
||||||
|
.contador {
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: var(--raio-pill);
|
||||||
|
background: var(--superficie-3);
|
||||||
|
color: var(--texto-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SIMBOLO_EMOJI } from '../treinamento.svelte';
|
||||||
|
|
||||||
|
// Prévia estática de uma reação (sem iframe): figurinha SVG, emoji ou olhos do 1º passo.
|
||||||
|
let { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
|
||||||
|
const passo = $derived(receita?.passos?.[0]);
|
||||||
|
const simbolo = $derived(receita?.emoji?.caractere || SIMBOLO_EMOJI[receita?.simbolo] || '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
|
||||||
|
{#if receita?.figurinha?.formas}
|
||||||
|
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}>
|
||||||
|
{#each receita.figurinha.formas as forma, i (i)}
|
||||||
|
<path d={forma.caminho} fill={forma.cor} />
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
{:else if receita?.emoji}
|
||||||
|
<span class="emoji">{receita.emoji.caractere}</span>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 100 60" width={tamanho} height={tamanho * 0.6}>
|
||||||
|
{#if passo?.olhos === 'feliz'}
|
||||||
|
<path d="M18 38 Q30 18 42 38 M58 38 Q70 18 82 38" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
|
||||||
|
{:else if passo?.olhos === 'triste'}
|
||||||
|
<path d="M18 26 Q30 44 42 26 M58 26 Q70 44 82 26" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
|
||||||
|
{:else}
|
||||||
|
{@const ry = passo?.olhos === 'surpreso' ? 20 : 16}
|
||||||
|
<ellipse cx="30" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
|
||||||
|
<ellipse cx="70" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{#if simbolo}<span class="simbolo">{simbolo}</span>{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.miniatura {
|
||||||
|
position: relative;
|
||||||
|
width: var(--t);
|
||||||
|
height: var(--t);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--raio);
|
||||||
|
background: #05080c;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.emoji {
|
||||||
|
font-size: calc(var(--t) * 0.55);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.simbolo {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 0;
|
||||||
|
font-size: calc(var(--t) * 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X } from '@lucide/svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
// <dialog> nativo: foco preso, Esc e fundo vêm de graça.
|
||||||
|
let { aberto = $bindable(false), titulo, children }: { aberto: boolean; titulo: string; children: Snippet } = $props();
|
||||||
|
let dialogo: HTMLDialogElement;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (aberto && !dialogo.open) dialogo.showModal();
|
||||||
|
else if (!aberto && dialogo.open) dialogo.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialogo}
|
||||||
|
onclose={() => (aberto = false)}
|
||||||
|
onclick={(e) => e.target === dialogo && (aberto = false)}
|
||||||
|
aria-label={titulo}
|
||||||
|
>
|
||||||
|
<div class="conteudo">
|
||||||
|
<header>
|
||||||
|
<h2>{titulo}</h2>
|
||||||
|
<button class="btn fantasma icone pequeno" aria-label="Fechar" onclick={() => (aberto = false)}><X size={18} /></button>
|
||||||
|
</header>
|
||||||
|
{#if aberto}{@render children()}{/if}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {
|
||||||
|
width: min(480px, calc(100vw - 2 * var(--e-4)));
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--borda-forte);
|
||||||
|
border-radius: var(--raio-lg);
|
||||||
|
background: var(--superficie);
|
||||||
|
color: var(--texto);
|
||||||
|
box-shadow: var(--sombra-flutuante);
|
||||||
|
}
|
||||||
|
dialog::backdrop {
|
||||||
|
background: rgb(0 0 0 / 0.5);
|
||||||
|
}
|
||||||
|
.conteudo {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
padding: var(--e-5);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: var(--t-lg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -101,7 +101,7 @@ img, video, canvas, iframe { max-width: 100%; }
|
|||||||
.btn.fantasma:hover:not(:disabled) { background: var(--superficie-2); }
|
.btn.fantasma:hover:not(:disabled) { background: var(--superficie-2); }
|
||||||
.btn.icone { padding: 0; width: 40px; }
|
.btn.icone { padding: 0; width: 40px; }
|
||||||
.btn.pequeno { min-height: 32px; padding: 0 var(--e-3); font-size: var(--t-xs); }
|
.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 ---------- */
|
/* ---------- formulários ---------- */
|
||||||
.campo { display: grid; gap: var(--e-1); }
|
.campo { display: grid; gap: var(--e-1); }
|
||||||
|
|||||||
@@ -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<StatusTreinamento | null>(null);
|
||||||
|
carregando = $state(false);
|
||||||
|
|
||||||
|
async carregar() {
|
||||||
|
this.carregando = true;
|
||||||
|
try {
|
||||||
|
this.status = await pedir<StatusTreinamento>('/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<string, string> = { ideia: '💡', cafe: '☕', musica: '🎵', coracao: '❤️', estrela: '⭐' };
|
||||||
@@ -1,7 +1,403 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Mic, MicOff, Plus, Send, Trash2, Volume2, X } from '@lucide/svelte';
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { postar } from '../lib/api';
|
||||||
|
import { avisos, tentar } from '../lib/avisos.svelte';
|
||||||
|
import { gesto, GESTOS_INFO } from '../lib/comandos.svelte';
|
||||||
|
import Abas from '../lib/componentes/Abas.svelte';
|
||||||
import Card from '../lib/componentes/Card.svelte';
|
import Card from '../lib/componentes/Card.svelte';
|
||||||
|
import Selo from '../lib/componentes/Selo.svelte';
|
||||||
|
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||||
|
import { estado, ouvir } from '../lib/estado.svelte';
|
||||||
|
import { treinamento } from '../lib/treinamento.svelte';
|
||||||
|
|
||||||
|
const SUGESTOES = ['Oi, Carinha! Tudo bem?', 'Que horas são?', 'Me conta uma curiosidade', 'Como você está se sentindo?'];
|
||||||
|
|
||||||
|
let texto = $state('');
|
||||||
|
let enviando = $state(false);
|
||||||
|
let lista = $state<HTMLElement>();
|
||||||
|
const conversa = $derived(estado.conversa);
|
||||||
|
const ocupado = $derived(enviando || !!conversa?.ocupado);
|
||||||
|
|
||||||
|
async function enviar(mensagem = texto) {
|
||||||
|
const t = mensagem.trim();
|
||||||
|
if (!t || ocupado) return;
|
||||||
|
enviando = true;
|
||||||
|
try {
|
||||||
|
await postar('/conversa/enviar', { texto: t });
|
||||||
|
texto = '';
|
||||||
|
} catch (e) {
|
||||||
|
avisos.erro(e);
|
||||||
|
} finally {
|
||||||
|
enviando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rola para o fim quando chegam mensagens.
|
||||||
|
$effect(() => {
|
||||||
|
void conversa?.historico.length;
|
||||||
|
void conversa?.ocupado;
|
||||||
|
tick().then(() => lista?.scrollTo({ top: lista.scrollHeight, behavior: 'smooth' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- voz (Web Speech API): fala só respostas novas, nunca a antiga ao abrir
|
||||||
|
let lerEmVoz = $state(false);
|
||||||
|
try {
|
||||||
|
lerEmVoz = localStorage.getItem('carinha_voz') === '1';
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
function falar(t: string) {
|
||||||
|
if (!('speechSynthesis' in window) || !t) return;
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
const u = new SpeechSynthesisUtterance(t);
|
||||||
|
u.lang = 'pt-BR';
|
||||||
|
u.pitch = 1.35;
|
||||||
|
u.rate = 1.05;
|
||||||
|
const voz = speechSynthesis.getVoices().find((v) => v.lang.startsWith('pt'));
|
||||||
|
if (voz) u.voice = voz;
|
||||||
|
speechSynthesis.speak(u);
|
||||||
|
}
|
||||||
|
let ultimaFalada = estado.conversa?.ultima?.id;
|
||||||
|
onMount(() =>
|
||||||
|
ouvir('conversa', (c) => {
|
||||||
|
if (c.ultima && c.ultima.id !== ultimaFalada) {
|
||||||
|
ultimaFalada = c.ultima.id;
|
||||||
|
if (lerEmVoz) falar(c.ultima.texto);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const Reconhecimento = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||||
|
const vozDisponivel = !!Reconhecimento && window.isSecureContext;
|
||||||
|
const motivoSemVoz = !Reconhecimento
|
||||||
|
? 'Este navegador não reconhece voz.'
|
||||||
|
: 'O microfone do navegador exige HTTPS ou localhost.';
|
||||||
|
let gravando = $state(false);
|
||||||
|
let reconhecimento: any = null;
|
||||||
|
function microfone() {
|
||||||
|
if (gravando) return reconhecimento?.stop();
|
||||||
|
reconhecimento = new Reconhecimento();
|
||||||
|
reconhecimento.lang = 'pt-BR';
|
||||||
|
reconhecimento.interimResults = false;
|
||||||
|
reconhecimento.onstart = () => (gravando = true);
|
||||||
|
reconhecimento.onend = () => (gravando = false);
|
||||||
|
reconhecimento.onerror = (e: any) => {
|
||||||
|
gravando = false;
|
||||||
|
avisos.mostrar(e.error === 'not-allowed' ? 'Permissão do microfone negada.' : 'Microfone: ' + e.error, 'erro');
|
||||||
|
};
|
||||||
|
reconhecimento.onresult = (e: any) => {
|
||||||
|
texto = e.results[0][0].transcript;
|
||||||
|
enviar();
|
||||||
|
};
|
||||||
|
reconhecimento.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- memória: lembranças da conversa + rotinas do treinamento
|
||||||
|
type AbaMemoria = 'lembrancas' | 'rotinas';
|
||||||
|
let aba = $state<AbaMemoria>('lembrancas');
|
||||||
|
let nova = $state('');
|
||||||
|
const itensMemoria = $derived(aba === 'lembrancas' ? (conversa?.lembrancas ?? []) : (treinamento.status?.rotina ?? []));
|
||||||
|
onMount(() => {
|
||||||
|
treinamento.carregar();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function adicionar(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = nova.trim();
|
||||||
|
if (!t) return;
|
||||||
|
const ok =
|
||||||
|
aba === 'lembrancas'
|
||||||
|
? await tentar(() => postar('/conversa/lembrar', { texto: t }))
|
||||||
|
: await treinamento.acao('rotina', { texto: t });
|
||||||
|
if (ok) nova = '';
|
||||||
|
}
|
||||||
|
const apagarLembranca = (id: number) => tentar(() => postar('/conversa/apagar', { id }));
|
||||||
|
const apagarRotina = (id: number) => treinamento.acao('rotina-apagar', { id });
|
||||||
|
|
||||||
|
let confirmarLimpar = $state(false);
|
||||||
|
async function limparTudo() {
|
||||||
|
confirmarLimpar = false;
|
||||||
|
await tentar(() => postar('/conversa/limpar'), 'Conversa, lembranças e diário apagados.');
|
||||||
|
treinamento.carregar();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card titulo="Conversa" descricao="Chat, voz e memória migram na fase 3.">
|
<div class="conversa">
|
||||||
<p class="pequeno-texto">Enquanto isso, use a tela antiga: <a href="/conversa">abrir Conversa (versão anterior)</a>.</p>
|
<Card class="chat">
|
||||||
|
{#snippet acoes()}
|
||||||
|
{#if conversa}
|
||||||
|
<Selo tom="destaque">{conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'} · {conversa.modelo}</Selo>
|
||||||
|
<a class="pequeno-texto" href="#/ajustes">trocar</a>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="mensagens" bind:this={lista} aria-live="polite">
|
||||||
|
{#if !conversa?.historico.length}
|
||||||
|
<div class="boas-vindas">
|
||||||
|
<p>Converse com o Carinha. Ele responde aqui e reage com o rosto no celular.</p>
|
||||||
|
<div class="sugestoes">
|
||||||
|
{#each SUGESTOES as s (s)}
|
||||||
|
<button class="btn pequeno" onclick={() => enviar(s)} disabled={ocupado}>{s}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each conversa?.historico ?? [] as m, i (i)}
|
||||||
|
<div class="msg {m.role}">
|
||||||
|
<p>{m.content}</p>
|
||||||
|
{#if m.role === 'assistant'}
|
||||||
|
<button class="btn fantasma pequeno icone" aria-label="Ler em voz alta" onclick={() => falar(m.content)}>
|
||||||
|
<Volume2 size={14} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if conversa?.ocupado}
|
||||||
|
<div class="msg assistant pensando" aria-label="Carinha está pensando"><span></span><span></span><span></span></div>
|
||||||
|
{/if}
|
||||||
|
{#if conversa?.ultima && !conversa.ocupado && GESTOS_INFO[conversa.ultima.gesto]}
|
||||||
|
<button class="gesto-resposta" onclick={() => gesto(conversa.ultima!.gesto)}>
|
||||||
|
{GESTOS_INFO[conversa.ultima.gesto].emoji} reagiu com {GESTOS_INFO[conversa.ultima.gesto].nome.toLowerCase()} · repetir
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if conversa?.erro}<p class="erro pequeno-texto">{conversa.erro}</p>{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="entrada"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
enviar();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn icone"
|
||||||
|
class:gravando
|
||||||
|
onclick={microfone}
|
||||||
|
disabled={!vozDisponivel || ocupado}
|
||||||
|
title={vozDisponivel ? (gravando ? 'Parar de ouvir' : 'Falar') : motivoSemVoz}
|
||||||
|
aria-label={gravando ? 'Parar de ouvir' : 'Falar'}
|
||||||
|
>
|
||||||
|
{#if vozDisponivel}<Mic size={18} />{:else}<MicOff size={18} />{/if}
|
||||||
|
</button>
|
||||||
|
<input type="text" bind:value={texto} placeholder="Escreva para o Carinha…" maxlength="600" aria-label="Mensagem" />
|
||||||
|
<button class="btn primario" type="submit" disabled={ocupado || !texto.trim()}>
|
||||||
|
<Send size={16} /> <span class="rotulo-enviar">Enviar</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="linha entre">
|
||||||
|
<Toggle
|
||||||
|
rotulo="Ler respostas em voz alta neste PC"
|
||||||
|
checked={lerEmVoz}
|
||||||
|
onchange={(v) => {
|
||||||
|
lerEmVoz = v;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('carinha_voz', v ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card titulo="Memória" descricao="O que o Carinha leva em conta ao responder. São dados, nunca instruções.">
|
||||||
|
<Abas
|
||||||
|
rotulo="Memória"
|
||||||
|
bind:ativa={aba}
|
||||||
|
abas={[
|
||||||
|
{ id: 'lembrancas', titulo: 'Lembranças', contador: conversa?.lembrancas.length ?? 0 },
|
||||||
|
{ id: 'rotinas', titulo: 'Rotina e preferências', contador: treinamento.status?.rotina.length ?? 0 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<form class="linha nova" onsubmit={adicionar}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={nova}
|
||||||
|
maxlength="300"
|
||||||
|
placeholder={aba === 'lembrancas' ? 'Ex.: Meu nome é Júlio' : 'Ex.: Treino às 7h de segunda a sexta'}
|
||||||
|
aria-label={aba === 'lembrancas' ? 'Nova lembrança' : 'Nova rotina'}
|
||||||
|
/>
|
||||||
|
<button class="btn" type="submit" disabled={!nova.trim()}><Plus size={16} /> Adicionar</button>
|
||||||
|
</form>
|
||||||
|
<ul class="itens">
|
||||||
|
{#each itensMemoria as item (item.id)}
|
||||||
|
<li>
|
||||||
|
<span>{item.texto}</span>
|
||||||
|
<button
|
||||||
|
class="btn fantasma pequeno icone"
|
||||||
|
aria-label="Apagar"
|
||||||
|
onclick={() => (aba === 'lembrancas' ? apagarLembranca(item.id) : apagarRotina(item.id))}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="fraco pequeno-texto">Nada guardado ainda.</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div class="perigo-zona">
|
||||||
|
{#if confirmarLimpar}
|
||||||
|
<p class="pequeno-texto">Apagar conversa, lembranças e diário? Rotinas e reações ficam.</p>
|
||||||
|
<div class="linha">
|
||||||
|
<button class="btn perigo pequeno" onclick={limparTudo}>Sim, apagar</button>
|
||||||
|
<button class="btn pequeno" onclick={() => (confirmarLimpar = false)}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="btn fantasma pequeno" onclick={() => (confirmarLimpar = true)}>
|
||||||
|
<Trash2 size={14} /> Apagar conversa e lembranças
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.conversa {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
:global(.chat) {
|
||||||
|
grid-template-rows: auto 1fr auto auto;
|
||||||
|
}
|
||||||
|
.mensagens {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--e-3);
|
||||||
|
min-height: 320px;
|
||||||
|
max-height: min(60vh, 640px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--e-1);
|
||||||
|
}
|
||||||
|
.boas-vindas {
|
||||||
|
margin: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--texto-2);
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.sugestoes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--e-2);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--e-1);
|
||||||
|
max-width: 82%;
|
||||||
|
}
|
||||||
|
.msg p {
|
||||||
|
padding: var(--e-2) var(--e-3);
|
||||||
|
border-radius: var(--raio);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
.msg.user p {
|
||||||
|
background: var(--destaque);
|
||||||
|
color: var(--destaque-texto);
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.msg.assistant p {
|
||||||
|
background: var(--superficie-2);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
.pensando {
|
||||||
|
gap: 4px;
|
||||||
|
padding: var(--e-3);
|
||||||
|
border-radius: var(--raio);
|
||||||
|
background: var(--superficie-2);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.pensando span {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--texto-3);
|
||||||
|
animation: pulo 1s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
.pensando span:nth-child(2) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
.pensando span:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
@keyframes pulo {
|
||||||
|
50% {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.gesto-resposta {
|
||||||
|
align-self: flex-start;
|
||||||
|
border: 1px dashed var(--borda-forte);
|
||||||
|
border-radius: var(--raio-pill);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-2);
|
||||||
|
font: var(--t-xs) var(--fonte);
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.entrada {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--e-2);
|
||||||
|
}
|
||||||
|
.gravando {
|
||||||
|
background: var(--perigo-suave);
|
||||||
|
border-color: var(--perigo);
|
||||||
|
color: var(--perigo);
|
||||||
|
}
|
||||||
|
.erro {
|
||||||
|
color: var(--perigo);
|
||||||
|
}
|
||||||
|
.nova input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.itens {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-1);
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.itens li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: var(--e-2) var(--e-3);
|
||||||
|
border-radius: var(--raio-sm);
|
||||||
|
background: var(--superficie-2);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
.perigo-zona {
|
||||||
|
border-top: 1px solid var(--borda);
|
||||||
|
padding-top: var(--e-3);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.rotulo-enviar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.conversa {
|
||||||
|
grid-template-columns: 3fr 2fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,449 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Copy, Download, Eye, EyeOff, Pencil, Plus, Search, Send, Sparkles, Square, ThumbsDown, ThumbsUp, Trash2, Undo2,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { postar } from '../lib/api';
|
||||||
|
import { avisos, tentar } from '../lib/avisos.svelte';
|
||||||
|
import Abas from '../lib/componentes/Abas.svelte';
|
||||||
import Card from '../lib/componentes/Card.svelte';
|
import Card from '../lib/componentes/Card.svelte';
|
||||||
|
import Gestos from '../lib/componentes/Gestos.svelte';
|
||||||
|
import Miniatura from '../lib/componentes/Miniatura.svelte';
|
||||||
|
import Modal from '../lib/componentes/Modal.svelte';
|
||||||
|
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||||
|
import Selo from '../lib/componentes/Selo.svelte';
|
||||||
|
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||||
|
import { treinamento, type ReacaoBiblioteca } from '../lib/treinamento.svelte';
|
||||||
|
|
||||||
|
type Aba = 'biblioteca' | 'criar' | 'gestos' | 'diario';
|
||||||
|
let aba = $state<Aba>('biblioteca');
|
||||||
|
let previa = $state<{ previa: (receita: unknown) => void }>();
|
||||||
|
let busca = $state('');
|
||||||
|
const st = $derived(treinamento.status);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
treinamento.carregar();
|
||||||
|
});
|
||||||
|
|
||||||
|
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||||
|
const reacoes = $derived(
|
||||||
|
(st?.reacoes ?? []).filter((r) => !busca || normal(r.receita.nome + ' ' + r.receita.motivo).includes(normal(busca))),
|
||||||
|
);
|
||||||
|
const tipo = (r: ReacaoBiblioteca) => (r.receita.figurinha ? 'Desenho' : r.receita.emoji ? 'Emoji' : 'Animação');
|
||||||
|
|
||||||
|
// ---------- ações da biblioteca
|
||||||
|
const mostrar = (r: ReacaoBiblioteca) => tentar(() => postar('/comando', { tipo: 'reacao', valor: r.id }), 'Enviada ao celular.');
|
||||||
|
const pararNoCelular = () => {
|
||||||
|
previa?.previa({ limpar: true });
|
||||||
|
tentar(() => postar('/comando', { tipo: 'parar_reacao' }));
|
||||||
|
};
|
||||||
|
const biblioteca = (dados: object, sucesso?: string) => treinamento.acao('biblioteca', dados, sucesso);
|
||||||
|
const desfazer = () => biblioteca({ acao: 'desfazer' }, 'Alteração desfeita.');
|
||||||
|
|
||||||
|
async function excluir(r: ReacaoBiblioteca) {
|
||||||
|
if (await biblioteca({ acao: 'excluir', id: r.id }))
|
||||||
|
avisos.ok(`“${r.receita.nome}” excluída.`, { rotulo: 'Desfazer', fazer: desfazer });
|
||||||
|
}
|
||||||
|
const feedback = (r: ReacaoBiblioteca, valor: -1 | 1) =>
|
||||||
|
treinamento.acao('feedback', { id: r.id, valor: r.feedback === valor ? 0 : valor });
|
||||||
|
|
||||||
|
function exportar() {
|
||||||
|
if (!st) return;
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([JSON.stringify(st.reacoes.map((r) => r.receita), null, 2)], { type: 'application/json' }),
|
||||||
|
);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'carinha-reacoes.json';
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- editor
|
||||||
|
let editorAberto = $state(false);
|
||||||
|
let editando = $state<ReacaoBiblioteca | null>(null);
|
||||||
|
let form = $state({ nome: '', motivo: '', caractere: '💡', local: 'olhos' });
|
||||||
|
function abrirEditor(r: ReacaoBiblioteca | null) {
|
||||||
|
editando = r;
|
||||||
|
form = {
|
||||||
|
nome: r?.receita.nome ?? '',
|
||||||
|
motivo: r?.receita.motivo ?? '',
|
||||||
|
caractere: r?.receita.emoji?.caractere ?? (r ? '' : '💡'),
|
||||||
|
local: r?.receita.emoji?.local ?? r?.receita.figurinha?.local ?? 'olhos',
|
||||||
|
};
|
||||||
|
editorAberto = true;
|
||||||
|
}
|
||||||
|
async function salvarEditor(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dados = { acao: editando ? 'editar' : 'criar', id: editando?.id ?? null, ...form, nome: form.nome.trim(), motivo: form.motivo.trim() };
|
||||||
|
if (await biblioteca(dados, editando ? 'Reação atualizada.' : 'Emoji criado.')) editorAberto = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- criar com IA
|
||||||
|
let pedido = $state('');
|
||||||
|
async function desenhar(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (await treinamento.acao('figurinha', { texto: pedido.trim() }, 'Pedido enviado ao Gemini. A figurinha aparece no celular quando ficar pronta.'))
|
||||||
|
pedido = '';
|
||||||
|
}
|
||||||
|
let confirmarDiario = $state(false);
|
||||||
|
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card titulo="Reações" descricao="Biblioteca e criação com IA migram na fase 3.">
|
<div class="reacoes">
|
||||||
<p class="pequeno-texto">Enquanto isso, use a tela antiga: <a href="/emojis">abrir Reações (versão anterior)</a>.</p>
|
<div class="principal">
|
||||||
|
<Abas
|
||||||
|
rotulo="Reações"
|
||||||
|
bind:ativa={aba}
|
||||||
|
abas={[
|
||||||
|
{ id: 'biblioteca', titulo: 'Biblioteca', contador: st?.reacoes.length },
|
||||||
|
{ id: 'criar', titulo: 'Criar com IA' },
|
||||||
|
{ id: 'gestos', titulo: 'Gestos do rosto' },
|
||||||
|
{ id: 'diario', titulo: 'Diário' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if aba === 'biblioteca'}
|
||||||
|
<div class="barra">
|
||||||
|
<label class="busca">
|
||||||
|
<Search size={16} />
|
||||||
|
<input type="search" bind:value={busca} placeholder="Buscar reação" aria-label="Buscar reação" />
|
||||||
|
</label>
|
||||||
|
<button class="btn primario" onclick={() => abrirEditor(null)} disabled={st?.ocupado}><Plus size={16} /> Novo emoji</button>
|
||||||
|
<button class="btn" onclick={desfazer} disabled={!st?.desfazer || st?.ocupado}><Undo2 size={16} /> Desfazer</button>
|
||||||
|
<button class="btn" onclick={pararNoCelular}><Square size={16} /> Parar no celular</button>
|
||||||
|
<button class="btn fantasma" onclick={exportar} disabled={!st?.reacoes.length}><Download size={16} /> Exportar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !st}
|
||||||
|
<p class="fraco">Carregando biblioteca…</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="grade-reacoes">
|
||||||
|
{#each reacoes as r (r.id)}
|
||||||
|
<li class="reacao" class:inativa={!r.ativa}>
|
||||||
|
<button class="topo" onclick={() => previa?.previa(r.receita)} title="Ver na prévia">
|
||||||
|
<Miniatura receita={r.receita} />
|
||||||
|
<div class="info">
|
||||||
|
<strong>{r.receita.nome}</strong>
|
||||||
|
<span class="fraco pequeno-texto">{r.receita.motivo}</span>
|
||||||
|
<span class="linha">
|
||||||
|
<Selo>{tipo(r)}</Selo>
|
||||||
|
{#if !r.ativa}<Selo tom="aviso">Desativada</Selo>{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div class="acoes">
|
||||||
|
<button class="btn pequeno primario" onclick={() => mostrar(r)}><Send size={14} /> Mostrar</button>
|
||||||
|
<button
|
||||||
|
class="btn pequeno icone"
|
||||||
|
class:marcado={r.feedback === 1}
|
||||||
|
aria-pressed={r.feedback === 1}
|
||||||
|
aria-label="Gostei"
|
||||||
|
onclick={() => feedback(r, 1)}><ThumbsUp size={14} /></button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn pequeno icone"
|
||||||
|
class:marcado-ruim={r.feedback === -1}
|
||||||
|
aria-pressed={r.feedback === -1}
|
||||||
|
aria-label="Não gostei"
|
||||||
|
onclick={() => feedback(r, -1)}><ThumbsDown size={14} /></button
|
||||||
|
>
|
||||||
|
<span class="espaco"></span>
|
||||||
|
<button class="btn pequeno icone fantasma" aria-label="Editar" title="Editar" onclick={() => abrirEditor(r)}><Pencil size={14} /></button>
|
||||||
|
<button class="btn pequeno icone fantasma" aria-label="Duplicar" title="Duplicar" onclick={() => biblioteca({ acao: 'duplicar', id: r.id }, 'Reação duplicada.')}
|
||||||
|
><Copy size={14} /></button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn pequeno icone fantasma"
|
||||||
|
aria-label={r.ativa ? 'Desativar' : 'Ativar'}
|
||||||
|
title={r.ativa ? 'Desativar (a IA deixa de usar)' : 'Ativar'}
|
||||||
|
onclick={() => biblioteca({ acao: 'ativar', id: r.id, ativa: !r.ativa })}
|
||||||
|
>
|
||||||
|
{#if r.ativa}<EyeOff size={14} />{:else}<Eye size={14} />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="btn pequeno icone fantasma perigo-texto" aria-label="Excluir" title="Excluir" onclick={() => excluir(r)}><Trash2 size={14} /></button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="fraco">{busca ? 'Nenhuma reação encontrada.' : 'Crie um emoji ou peça um desenho ao Gemini.'}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{:else if aba === 'criar'}
|
||||||
|
<div class="grade-criar">
|
||||||
|
<Card titulo="Desenhar figurinha" descricao="O Gemini desenha um SVG e o celular mostra assim que ficar pronto.">
|
||||||
|
<form class="grade" onsubmit={desenhar}>
|
||||||
|
<label class="campo">
|
||||||
|
<span>O que desenhar</span>
|
||||||
|
<textarea bind:value={pedido} maxlength="1000" placeholder="Ex.: um sol de óculos escuros sorrindo" required></textarea>
|
||||||
|
</label>
|
||||||
|
<button class="btn primario" type="submit" disabled={st?.ocupado || !pedido.trim()}>
|
||||||
|
<Sparkles size={16} /> {st?.ocupado ? 'Gemini trabalhando…' : 'Desenhar e mostrar'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if st?.ultimo_prompt_figurinha}
|
||||||
|
<details>
|
||||||
|
<summary class="pequeno-texto fraco">Último pedido enviado</summary>
|
||||||
|
<pre class="mono">{st.ultimo_prompt_figurinha}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card titulo="Reações automáticas" descricao="A partir da conversa e da rotina, o Gemini inventa 3 animações novas.">
|
||||||
|
<button class="btn" onclick={() => treinamento.acao('gerar', {}, 'Gemini está criando 3 reações…')} disabled={st?.ocupado}>
|
||||||
|
<Sparkles size={16} /> Gerar 3 agora
|
||||||
|
</button>
|
||||||
|
<Toggle
|
||||||
|
rotulo="Gerar sozinho a cada hora"
|
||||||
|
ajuda="Cada geração é uma chamada paga ao Gemini"
|
||||||
|
checked={!!st?.automatico}
|
||||||
|
disabled={!st}
|
||||||
|
onchange={(v) => treinamento.acao('config', { automatico: v })}
|
||||||
|
/>
|
||||||
|
{#if st?.ocupado}<Selo tom="aviso">Gemini trabalhando…</Selo>{/if}
|
||||||
|
{#if st?.erro}<p class="erro pequeno-texto">{st.erro}</p>{/if}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{:else if aba === 'gestos'}
|
||||||
|
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (atalhos 1 a 9).">
|
||||||
|
<Gestos completo />
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<div class="grade-criar">
|
||||||
|
<Card titulo="Resumos">
|
||||||
|
{#each st?.resumos ?? [] as r (r.id)}
|
||||||
|
<article class="entrada"><time class="fraco pequeno-texto">{hora(r.horario)}</time><p>{r.texto}</p></article>
|
||||||
|
{:else}
|
||||||
|
<p class="fraco pequeno-texto">Os resumos aparecem quando o Gemini gera reações.</p>
|
||||||
|
{/each}
|
||||||
|
</Card>
|
||||||
|
<Card titulo="Diário">
|
||||||
|
{#snippet acoes()}
|
||||||
|
{#if confirmarDiario}
|
||||||
|
<button
|
||||||
|
class="btn perigo pequeno"
|
||||||
|
onclick={async () => {
|
||||||
|
confirmarDiario = false;
|
||||||
|
await treinamento.acao('limpar-diario', {}, 'Diário e resumos apagados.');
|
||||||
|
}}>Apagar diário</button
|
||||||
|
>
|
||||||
|
<button class="btn pequeno" onclick={() => (confirmarDiario = false)}>Cancelar</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn pequeno fantasma" onclick={() => (confirmarDiario = true)} disabled={!st?.diario.length}>
|
||||||
|
<Trash2 size={14} /> Limpar
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<div class="diario">
|
||||||
|
{#each st?.diario ?? [] as m (m.id)}
|
||||||
|
<article class="entrada">
|
||||||
|
<time class="fraco pequeno-texto">{hora(m.horario)} · {m.papel === 'user' ? 'Você' : 'Carinha'}</time>
|
||||||
|
<p>{m.texto}</p>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<p class="fraco pequeno-texto">Vazio.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="lateral">
|
||||||
|
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" /></div>
|
||||||
|
<p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal bind:aberto={editorAberto} titulo={editando ? 'Editar reação' : 'Novo emoji'}>
|
||||||
|
<form class="grade" onsubmit={salvarEditor}>
|
||||||
|
<label class="campo"><span>Nome</span><input type="text" bind:value={form.nome} maxlength="50" required /></label>
|
||||||
|
<label class="campo"><span>Quando usar</span><input type="text" bind:value={form.motivo} maxlength="200" required /></label>
|
||||||
|
{#if !editando?.receita.figurinha}
|
||||||
|
<div class="campo">
|
||||||
|
<span>Emoji</span>
|
||||||
|
<div class="emojis" role="radiogroup" aria-label="Emoji">
|
||||||
|
{#each st?.emojis ?? [] as e (e)}
|
||||||
|
<button type="button" role="radio" aria-checked={form.caractere === e} onclick={() => (form.caractere = e)}>{e}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="campo">
|
||||||
|
<span>Onde aparece</span>
|
||||||
|
<select bind:value={form.local}>
|
||||||
|
<option value="olhos">Nos olhos</option>
|
||||||
|
<option value="ao_redor">Ao redor do rosto</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<div class="linha">
|
||||||
|
<button class="btn primario" type="submit" disabled={!editando && !form.caractere}>Salvar</button>
|
||||||
|
<button class="btn" type="button" onclick={() => (editorAberto = false)}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reacoes {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.principal {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.barra {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--e-2);
|
||||||
|
}
|
||||||
|
.busca {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding-left: var(--e-3);
|
||||||
|
border: 1px solid var(--borda-forte);
|
||||||
|
border-radius: var(--raio-sm);
|
||||||
|
background: var(--fundo-2);
|
||||||
|
color: var(--texto-3);
|
||||||
|
}
|
||||||
|
.busca input {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.grade-reacoes {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-3);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
|
||||||
|
}
|
||||||
|
.reacao {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: var(--e-3);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: var(--raio-lg);
|
||||||
|
background: var(--superficie);
|
||||||
|
}
|
||||||
|
.reacao.inativa {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.topo {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--e-3);
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
.info strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.acoes {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--e-1);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.espaco {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.marcado {
|
||||||
|
color: var(--ok);
|
||||||
|
border-color: var(--ok);
|
||||||
|
}
|
||||||
|
.marcado-ruim {
|
||||||
|
color: var(--perigo);
|
||||||
|
border-color: var(--perigo);
|
||||||
|
}
|
||||||
|
.perigo-texto:hover {
|
||||||
|
color: var(--perigo);
|
||||||
|
}
|
||||||
|
.grade-criar {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
color: var(--texto-2);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.erro {
|
||||||
|
color: var(--perigo);
|
||||||
|
}
|
||||||
|
.entrada {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
.diario {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-3);
|
||||||
|
max-height: 480px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.lateral {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
}
|
||||||
|
.quadro {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: var(--raio-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.emojis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.emojis button {
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: var(--raio-sm);
|
||||||
|
background: var(--superficie-2);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.emojis button[aria-checked='true'] {
|
||||||
|
border-color: var(--destaque);
|
||||||
|
background: var(--destaque-suave);
|
||||||
|
}
|
||||||
|
@media (max-width: 1099px) {
|
||||||
|
.lateral {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.reacoes {
|
||||||
|
grid-template-columns: 1fr 340px;
|
||||||
|
}
|
||||||
|
.lateral {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--topo-altura) + var(--e-4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user