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:
@@ -1,7 +1,403 @@
|
||||
<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 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>
|
||||
|
||||
<Card titulo="Conversa" descricao="Chat, voz e memória migram na fase 3.">
|
||||
<p class="pequeno-texto">Enquanto isso, use a tela antiga: <a href="/conversa">abrir Conversa (versão anterior)</a>.</p>
|
||||
</Card>
|
||||
<div class="conversa">
|
||||
<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 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>
|
||||
|
||||
Reference in New Issue
Block a user