feat(criacao): estúdio para criar expressões, atividades e figurinhas com IA
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>
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
<script lang="ts">
|
||||
import { Cpu, Settings2, Sparkles, Star, Trash2, Wand2 } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { favoritos } from '../lib/comandos.svelte';
|
||||
import Card from '../lib/componentes/Card.svelte';
|
||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||
import Selo from '../lib/componentes/Selo.svelte';
|
||||
import { criacao, TIPOS, type IaCriacao, type TipoCriacao } from '../lib/criacao.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
|
||||
// Estúdio: escolhe o que criar e com qual IA, escreve o pedido (pode partir de algo que já existe)
|
||||
// e as criações ficam numa grade igual à dos favoritos.
|
||||
onMount(() => criacao.carregar());
|
||||
const st = $derived(criacao.status);
|
||||
|
||||
let tipo = $state<TipoCriacao>('expressao');
|
||||
let ia = $state<IaCriacao>('gemini');
|
||||
let pedido = $state('');
|
||||
let base = $state('');
|
||||
let filtro = $state<'todos' | TipoCriacao>('todos');
|
||||
let configAberta = $state(false);
|
||||
let url = $state('');
|
||||
let modelo = $state('');
|
||||
let chave = $state('');
|
||||
|
||||
$effect(() => {
|
||||
// base só vale para o tipo atual
|
||||
tipo;
|
||||
base = '';
|
||||
});
|
||||
$effect(() => {
|
||||
const l = st?.provedores.local;
|
||||
if (l && !url && !modelo) {
|
||||
url = l.url ?? '';
|
||||
modelo = l.modelo ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
const IAS: { id: IaCriacao; icone: string }[] = [
|
||||
{ id: 'gemini', icone: '✨' },
|
||||
{ id: 'local', icone: '🖥️' },
|
||||
{ id: 's10', icone: '📱' },
|
||||
];
|
||||
const tipoInfo = $derived(TIPOS.find((t) => t.id === tipo)!);
|
||||
const bases = $derived(
|
||||
tipo === 'figurinha' || !st
|
||||
? []
|
||||
: [
|
||||
...st.bases[tipo].map((b) => ({ id: b.id, rotulo: `${b.emoji} ${b.nome}` })),
|
||||
...st.criacoes.filter((c) => c.tipo === tipo).map((c) => ({ id: `criado:${c.id}`, rotulo: `${c.emoji} ${c.nome} (criado)` })),
|
||||
],
|
||||
);
|
||||
const lista = $derived((st?.criacoes ?? []).filter((c) => filtro === 'todos' || c.tipo === filtro));
|
||||
const ocupado = $derived(st?.ocupado ?? null);
|
||||
const segundos = $derived(ocupado ? Math.max(0, Math.round(estado.agora / 1000 - ocupado.desde)) : 0);
|
||||
const provedor = $derived(st?.provedores[ia]);
|
||||
|
||||
async function criar(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (await criacao.criar(tipo, pedido.trim(), ia, base)) pedido = '';
|
||||
}
|
||||
async function salvarLocal(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const dados: { url: string; modelo: string; chave?: string } = { url: url.trim(), modelo: modelo.trim() };
|
||||
if (chave.trim()) dados.chave = chave.trim();
|
||||
await criacao.configurar(dados);
|
||||
chave = '';
|
||||
}
|
||||
const nomeTipo = (t: TipoCriacao) => TIPOS.find((x) => x.id === t)?.nome ?? t;
|
||||
const podeFavoritar = (t: TipoCriacao) => t !== 'figurinha';
|
||||
</script>
|
||||
|
||||
<div class="criar">
|
||||
<div class="topo">
|
||||
<Card titulo="Criar com IA" ajuda="criar" descricao="A IA monta a criação só com peças que o Carinha já sabe animar, usando exemplos do que já existe.">
|
||||
<form class="form" onsubmit={criar}>
|
||||
<div class="campo">
|
||||
<span>O que criar</span>
|
||||
<div class="segmentado" role="radiogroup" aria-label="O que criar">
|
||||
{#each TIPOS as t (t.id)}
|
||||
<button type="button" role="radio" aria-checked={tipo === t.id} onclick={() => (tipo = t.id)}>
|
||||
<span aria-hidden="true">{t.emoji}</span> {t.nome}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="campo">
|
||||
<span>Qual IA</span>
|
||||
<div class="ias" role="radiogroup" aria-label="Qual IA">
|
||||
{#each IAS as x (x.id)}
|
||||
{@const p = st?.provedores[x.id]}
|
||||
<button type="button" role="radio" class="ia" aria-checked={ia === x.id} class:indisponivel={p && !p.pronto} onclick={() => (ia = x.id)}>
|
||||
<span class="ia-nome"><span aria-hidden="true">{x.icone}</span> {p?.nome ?? x.id}</span>
|
||||
<span class="ia-detalhe">{p ? (p.pronto ? p.detalhe : p.motivo) : '…'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="campo">
|
||||
<span>Pedido</span>
|
||||
<textarea bind:value={pedido} maxlength="600" placeholder={tipoInfo.exemplo} required></textarea>
|
||||
</label>
|
||||
|
||||
{#if bases.length}
|
||||
<label class="campo">
|
||||
<span>Basear em (opcional)</span>
|
||||
<select bind:value={base}>
|
||||
<option value="">Nada: criar do zero (com exemplos do que existe)</option>
|
||||
{#each bases as b (b.id)}<option value={b.id}>{b.rotulo}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="linha acoes">
|
||||
<button class="btn primario" type="submit" disabled={!!ocupado || !pedido.trim() || !provedor?.pronto}>
|
||||
<Wand2 size={16} /> {ocupado ? 'Criando…' : `Criar ${nomeTipo(tipo).toLowerCase()}`}
|
||||
</button>
|
||||
<button class="btn fantasma pequeno" type="button" onclick={() => (configAberta = !configAberta)}>
|
||||
<Settings2 size={14} /> IA local
|
||||
</button>
|
||||
</div>
|
||||
{#if ocupado}
|
||||
<Selo tom="aviso">
|
||||
{st?.provedores[ocupado.ia]?.nome} criando {nomeTipo(ocupado.tipo).toLowerCase()}… {segundos}s
|
||||
{ocupado.ia !== 'gemini' ? '(IA local pode levar alguns minutos)' : ''}
|
||||
</Selo>
|
||||
{:else if st?.erro}
|
||||
<p class="erro pequeno-texto">{st.erro}</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if configAberta}
|
||||
<form class="form local" onsubmit={salvarLocal}>
|
||||
<p class="fraco pequeno-texto">
|
||||
Servidor compatível com OpenAI na sua rede (LM Studio com "Serve on Local Network" ou llama-server), por exemplo o ROG Ally.
|
||||
</p>
|
||||
<label class="campo"><span>Endereço</span><input type="text" bind:value={url} placeholder="http://192.168.1.50:1234" /></label>
|
||||
<label class="campo"><span>Modelo</span><input type="text" bind:value={modelo} placeholder="qwen3-coder-30b-a3b-instruct" /></label>
|
||||
<label class="campo">
|
||||
<span>Senha da API {st?.provedores.local.chave ? '(deixe vazio para manter)' : '(opcional)'}</span>
|
||||
<input type="password" bind:value={chave} autocomplete="off" />
|
||||
</label>
|
||||
<div class="linha">
|
||||
<button class="btn pequeno primario" type="submit">Salvar</button>
|
||||
<button class="btn pequeno" type="button" onclick={() => criacao.testar('local')} disabled={!st?.provedores.local.url}><Cpu size={14} /> Testar</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<div class="previa"><RostoPrevia titulo="Prévia das criações" espelharSono={false} /></div>
|
||||
</div>
|
||||
|
||||
<Card titulo="Criações" descricao="Toque para mostrar no celular. A estrela fixa expressões e atividades nos favoritos do Ao vivo.">
|
||||
{#snippet acoes()}
|
||||
<div class="segmentado pequeno" role="radiogroup" aria-label="Filtrar">
|
||||
<button role="radio" aria-checked={filtro === 'todos'} onclick={() => (filtro = 'todos')}>Todas</button>
|
||||
{#each TIPOS as t (t.id)}
|
||||
<button role="radio" aria-checked={filtro === t.id} onclick={() => (filtro = t.id)}>{t.emoji}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if !st}
|
||||
<p class="fraco">Carregando…</p>
|
||||
{:else if !lista.length}
|
||||
<p class="fraco">Nada criado ainda. Escreva um pedido acima. <Sparkles size={14} /></p>
|
||||
{:else}
|
||||
<div class="grade">
|
||||
{#each lista as c (c.id)}
|
||||
{@const fav = favoritos.lista.includes(`criado:${c.id}`)}
|
||||
<div class="item">
|
||||
<button class="cartao" onclick={() => criacao.tocar(c.id)} title={c.pedido}>
|
||||
<span class="emoji" aria-hidden="true">{c.emoji}</span>
|
||||
<span class="nome">{c.nome}</span>
|
||||
<span class="meta">{nomeTipo(c.tipo)} · {st.provedores[c.ia]?.nome ?? c.ia}</span>
|
||||
</button>
|
||||
{#if podeFavoritar(c.tipo)}
|
||||
<button
|
||||
class="canto estrela"
|
||||
class:ativa={fav}
|
||||
aria-pressed={fav}
|
||||
aria-label={fav ? `Tirar ${c.nome} dos favoritos` : `Fixar ${c.nome} nos favoritos`}
|
||||
onclick={() => favoritos.alternar(`criado:${c.id}`)}><Star size={14} /></button
|
||||
>
|
||||
{/if}
|
||||
<button class="canto lixo" aria-label={`Apagar ${c.nome}`} onclick={() => criacao.apagar(c.id)}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.criar {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
}
|
||||
.topo {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
align-items: start;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.topo {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
.previa {
|
||||
position: sticky;
|
||||
top: calc(var(--topo-altura) + var(--e-4));
|
||||
}
|
||||
}
|
||||
.previa {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--raio-lg);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border: 1px solid var(--borda);
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.form.local {
|
||||
margin-top: var(--e-3);
|
||||
padding-top: var(--e-3);
|
||||
border-top: 1px solid var(--borda);
|
||||
}
|
||||
textarea {
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
}
|
||||
.segmentado {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--raio-sm);
|
||||
background: var(--superficie-3);
|
||||
}
|
||||
.segmentado button {
|
||||
flex: 1;
|
||||
min-height: 34px;
|
||||
padding: 0 var(--e-3);
|
||||
border: 0;
|
||||
border-radius: calc(var(--raio-sm) - 2px);
|
||||
background: transparent;
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.segmentado.pequeno button {
|
||||
min-height: 28px;
|
||||
padding: 0 var(--e-2);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
.segmentado button[aria-checked='true'] {
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
box-shadow: var(--sombra);
|
||||
}
|
||||
.ias {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.ia {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: var(--e-2) var(--e-3);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ia[aria-checked='true'] {
|
||||
border-color: var(--destaque);
|
||||
background: var(--destaque-suave);
|
||||
}
|
||||
.ia.indisponivel .ia-detalhe {
|
||||
color: var(--aviso);
|
||||
}
|
||||
.ia-nome {
|
||||
font-weight: 600;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.ia-detalhe {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.acoes {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.erro {
|
||||
color: var(--perigo);
|
||||
}
|
||||
.grade {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.item {
|
||||
position: relative;
|
||||
}
|
||||
.cartao {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--e-3) var(--e-2) var(--e-2);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
|
||||
}
|
||||
.cartao:hover {
|
||||
border-color: var(--destaque);
|
||||
background: var(--destaque-suave);
|
||||
}
|
||||
.cartao:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.emoji {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.nome {
|
||||
font-size: var(--t-sm);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta {
|
||||
font-size: 10px;
|
||||
color: var(--texto-3);
|
||||
text-align: center;
|
||||
}
|
||||
.canto {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--texto-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.estrela {
|
||||
right: 2px;
|
||||
}
|
||||
.lixo {
|
||||
left: 2px;
|
||||
}
|
||||
.lixo:hover {
|
||||
color: var(--perigo);
|
||||
}
|
||||
.estrela.ativa {
|
||||
color: var(--aviso);
|
||||
}
|
||||
.estrela.ativa :global(svg) {
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user