- 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>
404 lines
12 KiB
Svelte
404 lines
12 KiB
Svelte
<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>
|
|
|
|
<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>
|