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:
julio
2026-10-06 22:22:52 -03:00
co-authored by Claude Opus 5.5
parent ff06f02705
commit 05e82dfd75
19 changed files with 1522 additions and 32 deletions
+3
View File
@@ -112,6 +112,9 @@ class Favoritos {
export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
/** Favorito pode ser um gesto ou uma criação do estúdio ("criado:12"). */
export const tocarFavorito = (g: string) =>
g.startsWith('criado:') ? tentar(() => postar('/criacao/tocar', { id: Number(g.slice(7)) })) : gesto(g);
export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
export const formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome }));
+23 -5
View File
@@ -1,17 +1,28 @@
<script lang="ts">
import { Star } from '@lucide/svelte';
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
import { favoritos, GESTOS_INFO, gestosDisponiveis, tocarFavorito } from '../comandos.svelte';
import { criacao } from '../criacao.svelte';
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
// compacto: só os favoritos com atalho 1–9 (dock), inclusive criações do estúdio. Completo: todos, com estrela.
let { completo = false }: { completo?: boolean } = $props();
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
$effect(() => {
if (!completo && !criacao.status && favoritos.lista.some((g) => g.startsWith('criado:'))) criacao.carregar();
});
function infoDe(g: string) {
if (g.startsWith('criado:')) {
const c = criacao.item(Number(g.slice(7)));
return c ? { nome: c.nome, emoji: c.emoji } : null;
}
return GESTOS_INFO[g] ?? null;
}
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => infoDe(g)));
</script>
<div class="gestos" class:completo>
{#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]}
{@const info = infoDe(g)!}
<div class="item">
<button class="gesto" onclick={() => gesto(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<button class="gesto" class:criado={g.startsWith('criado:')} onclick={() => tocarFavorito(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<span class="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span>
{#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
@@ -71,6 +82,13 @@
color: var(--texto-2);
text-align: center;
}
.gesto.criado::after {
content: '✨';
position: absolute;
left: 6px;
top: 4px;
font-size: 10px;
}
kbd {
position: absolute;
top: 4px;
+100
View File
@@ -0,0 +1,100 @@
/**
* Estúdio de criação (/criacao/*): expressões, atividades e figurinhas feitas por IA.
* O estado chega por SSE ('criacao') e é recarregado ao abrir a tela.
*/
import { pedir, postar } from './api';
import { avisos, tentar } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export type TipoCriacao = 'expressao' | 'atividade' | 'figurinha';
export type IaCriacao = 'gemini' | 'local' | 's10';
export interface ItemCriado {
id: number;
horario: number;
tipo: TipoCriacao;
nome: string;
emoji: string;
receita: any;
ia: IaCriacao;
pedido: string;
}
export interface Provedor {
nome: string;
pronto: boolean;
detalhe: string;
motivo: string;
url?: string;
modelo?: string;
chave?: boolean;
}
export interface StatusCriacao {
ocupado: { tipo: TipoCriacao; ia: IaCriacao; desde: number; pedido: string } | null;
erro: string;
provedores: Record<IaCriacao, Provedor>;
criacoes: ItemCriado[];
bases: Record<'expressao' | 'atividade', { id: string; nome: string; emoji: string }[]>;
}
export const TIPOS: { id: TipoCriacao; nome: string; emoji: string; exemplo: string }[] = [
{ id: 'expressao', nome: 'Expressão', emoji: '😊', exemplo: 'Ex.: desconfiado, olhando de lado com um olho meio fechado' },
{ id: 'atividade', nome: 'Atividade', emoji: '🤹', exemplo: 'Ex.: fazendo malabarismo com 3 bolinhas, concentrado' },
{ id: 'figurinha', nome: 'Figurinha', emoji: '🎨', exemplo: 'Ex.: um sol de óculos escuros sorrindo' },
];
class Criacao {
status = $state<StatusCriacao | null>(null);
async carregar() {
try {
this.status = await pedir<StatusCriacao>('/criacao/status');
} catch (e) {
avisos.erro(e);
}
}
item(id: number) {
return this.status?.criacoes.find((c) => c.id === id) ?? null;
}
async criar(tipo: TipoCriacao, pedido: string, ia: IaCriacao, base: string) {
const r = await tentar(() => postar<StatusCriacao>('/criacao/criar', { tipo, pedido, ia, base: base || null }));
if (r) this.status = r;
return !!r;
}
tocar(id: number) {
return tentar(() => postar('/criacao/tocar', { id }));
}
async apagar(id: number) {
await tentar(() => postar('/criacao/apagar', { id }), 'Criação apagada.');
}
async configurar(local: { url?: string; modelo?: string; chave?: string }) {
const r = await tentar(() => postar<StatusCriacao>('/criacao/config', { local }), 'IA local salva.');
if (r) this.status = r;
}
async testar(ia: IaCriacao) {
const r = await tentar(() => postar<{ mensagem: string }>('/criacao/testar', { ia }));
if (r) avisos.ok(r.mensagem);
}
}
export const criacao = new Criacao();
ouvir('criacao', (d: StatusCriacao) => {
const antes = criacao.status;
criacao.status = d;
// terminou: avisa o resultado
if (antes?.ocupado && !d.ocupado) {
if (d.erro) avisos.erro(new Error(d.erro));
else if (d.criacoes[0]) avisos.ok(`Criado: ${d.criacoes[0].emoji} ${d.criacoes[0].nome}`);
}
});
/** Favoritos podem guardar criações ("criado:12"): tocar no celular. */
export const ehCriado = (g: string) => g.startsWith('criado:');
export const idCriado = (g: string) => Number(g.slice(7));
+8 -2
View File
@@ -116,7 +116,13 @@ function aplicarFoto(f: EstadoServidor) {
const tratadores: Record<string, (d: any) => void> = {
expressao: (d: { ordem: string }) => {
// 'humor x' é o modo vida avisando o celular; aparece no cartão Vida, não no feed
if (d.ordem !== 'calma' && !d.ordem.startsWith('humor ')) noFeed('expressao', 'Expressão: ' + d.ordem);
if (d.ordem === 'calma' || /^(humor|ritmo|zanga|sono) /.test(d.ordem)) return;
const criada = /^(expressao|atividade)_criada (.*)$/.exec(d.ordem);
if (criada) {
let nome = '';
try { nome = JSON.parse(criada[2]).nome; } catch { /* ok */ }
noFeed('expressao', (criada[1] === 'expressao' ? 'Expressão criada: ' : 'Atividade criada: ') + nome);
} else noFeed('expressao', 'Expressão: ' + d.ordem);
},
telemetria: (d: Telemetria) => {
estado.app = d;
@@ -177,7 +183,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu';
};
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento', 'historico', 'pir', 'vida']) {
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'criacao']) {
fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados);
+19 -2
View File
@@ -179,6 +179,23 @@ export const TOPICOS: Record<string, TopicoGuia> = {
{ emoji: '👆', nome: 'Parar', texto: 'Botão Parar no painel ou um toque na tela do celular. Dormir também encerra a atividade.' },
],
},
criar: {
titulo: 'Criar com IA',
emoji: '🪄',
onde: 'Ao vivo › Gestos › Criar com IA (ou Reações › Criar com IA)',
resumo:
'Escreva um pedido e uma IA cria uma expressão, uma atividade ou uma figurinha nova. Ela não escreve código: monta a criação só com peças que o Carinha já sabe animar, com exemplos reais do que já existe. Tudo é conferido no servidor antes de chegar ao celular.',
itens: [
{ emoji: '😊', nome: 'Expressão', texto: 'Sequência de olhos, boca, cor, olhar e mãozinhas, como os gestos favoritos. Ex.: "desconfiado, olhando de lado".' },
{ emoji: '🤹', nome: 'Atividade', texto: 'O bonequinho faz algo com braços, pernas e objetos (bola, livro, notas, estrelas, halter, microfone, flor...). Ex.: "malabarismo com 3 bolinhas".' },
{ emoji: '🎨', nome: 'Figurinha', texto: 'Desenho que aparece nos olhos ou em volta do rosto.' },
{ emoji: '🧬', nome: 'Basear em', texto: 'Escolha um gesto, atividade ou criação que já existe e peça uma variação: a IA parte da receita dele.' },
{ emoji: '✨', nome: 'Gemini', texto: 'O melhor resultado e o mais rápido (alguns segundos). Usa a chave paga.' },
{ emoji: '🖥️', nome: 'IA local', texto: 'Um servidor na sua rede (LM Studio ou llama-server, como o ROG Ally). Grátis, mas pode levar alguns minutos e criar coisas mais simples. Configure em "IA local".' },
{ emoji: '📱', nome: 'Qwen do S10', texto: 'O modelo pequeno do celular. Rápido e grátis, mas fraco para criar: bom só para testar.' },
{ emoji: '⭐', nome: 'Favoritos', texto: 'A estrela põe expressões e atividades criadas na grade de favoritos do Ao vivo (e nas teclas 1 a 9).' },
],
},
personalidade: {
titulo: 'IA local (Qwen)',
emoji: '🧠',
@@ -302,7 +319,7 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'aparencia',
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'aparencia',
'personalidade', 'acontecimentos', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos',
];
@@ -310,5 +327,5 @@ export const ORDEM_GUIA = [
/** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar',
};
+1
View File
@@ -37,6 +37,7 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
{ id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') },
...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({
id: 'atividade-' + id, grupo: 'Atividades', titulo: a.emoji + ' ' + a.nome, fazer: () => atividade(id),
})),
+2 -1
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia';
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -15,6 +15,7 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
{ id: 'guia', titulo: 'Guia', volta: 'ajustes' },
{ id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' },
];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';