Menu "Criar com IA" (Ao vivo › Gestos e Reações) com grade igual à dos favoritos. Para cada pedido escolhe-se o tipo (expressão, atividade ou figurinha) e a IA: Gemini, IA local (qualquer servidor compatível com OpenAI na rede, como o ROG Ally com LM Studio/llama-server) ou o Qwen do S10. - A IA não escreve código: responde uma receita de formato fixo (schema JSON) montada só com peças que o rosto já anima. O servidor valida, limita números e descarta peças desconhecidas antes de chegar ao celular. - Reaproveita o que existe: o prompt leva exemplos reais dos gestos e atividades atuais, e "Basear em" manda a receita de um gesto, atividade ou criação para a IA fazer uma variação. - Expressões viram sequências como as de sequencias(), com mãos e espiral. - Atividades usam uma cena genérica com o bonequinho (poses, pernas, pulo, balanço) e os objetos das cenas feitas à mão (bola, livro, xícara, notas, estrelas, coração, Zzz, chama, bolha, halter, microfone, controle, pincel, flor), com movimentos pular/girar/subir/orbitar. - Figurinhas reaproveitam o desenho e a biblioteca de reações. - A estrela põe expressões e atividades criadas nos favoritos (e nas teclas 1 a 9); a IA local é configurada na própria tela, com teste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
461 lines
16 KiB
Svelte
461 lines
16 KiB
Svelte
<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 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>
|
|
|
|
<div class="reacoes">
|
|
<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="Estúdio de criação" ajuda="criar" descricao="Expressões, atividades e figurinhas novas, escolhendo a IA (Gemini, IA local ou Qwen do S10).">
|
|
<a class="btn primario" href="#/criar">Abrir o estúdio</a>
|
|
</Card>
|
|
<Card titulo="Desenhar figurinha" ajuda="reacoes" 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 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" ajuda="gestos" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (até 12; as teclas 1 a 9 valem para os nove primeiros).">
|
|
<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" espelharSono={false} /></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;
|
|
}
|
|
/* seis ações pequenas + Mostrar cabem numa linha num cartão de 300 px */
|
|
.acoes :global(.btn.pequeno.icone) {
|
|
width: 28px;
|
|
}
|
|
.acoes {
|
|
flex-wrap: nowrap;
|
|
gap: 2px;
|
|
}
|
|
.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>
|