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
+26 -2
View File
@@ -540,11 +540,31 @@ function passo(dt) { // avanca a fila; true se ha gesto em
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null;
const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
function atividade(nome, segundos) {
function atividade(nome, segundos, receita) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
if (window.MotorRosto.atividade(nome, segundos) && nome === "cantar") tocarSom("musica");
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
}
/* Expressão criada no estúdio (criacao.py): receita já validada no servidor, só números e nomes de
peças. Vira uma sequência igual às de sequencias(), com mãos e espiral se a receita pedir. */
function corDaReceita(nome) {
return { claro: "claro", escuro: "escuro", rosa: ROSA, amarelo: AMARELO, verde: VERDE, vermelho: VERM }[nome] || CIANO;
}
function tocarExpressaoCriada(texto) {
let rc;
try { rc = JSON.parse(texto); } catch (e) { return; }
if (!rc || !Array.isArray(rc.passos) || !rc.passos.length) return;
if (r.dormindo) r.dormindo = false;
r.fila = rc.passos.slice(0, 12).map((p) => [{
forma: p.forma || "aberto", forma2: null, boca: p.boca || "neutra", cor: corDaReceita(p.cor),
dx: +p.dx || 0, dy: +p.dy || 0, esc: +p.esc || 1, ab: typeof p.ab === "number" ? p.ab : 1,
}, Math.max(0.05, Math.min(2, +p.duracao || 0.3))]);
proximo();
const total = r.fila.reduce((a, passo) => a + passo[1], 0);
if (rc.espiral) ESPIRAL.ate = performance.now() / 1000 + total;
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
}
function repouso(t) { // pose calma: olha devagar, "respira"
@@ -689,6 +709,10 @@ function trata(ordem) {
HUMOR = ordem.slice(6).trim();
} else if (ordem.indexOf("ritmo ") === 0) {
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
} else if (ordem.indexOf("expressao_criada ") === 0) {
tocarExpressaoCriada(ordem.slice(17));
} else if (ordem.indexOf("atividade_criada ") === 0) {
try { const rc = JSON.parse(ordem.slice(17)); atividade("receita", +rc.duracao || 25, rc); } catch (e) {}
} else if (ordem.indexOf("atividade ") === 0) {
const [nome, seg] = ordem.slice(10).trim().split(/\s+/); // "atividade bola 180"
atividade(nome, parseFloat(seg) || 0);
+124 -5
View File
@@ -568,15 +568,16 @@
skate: { nome: 'Andando de skate', emoji: '🛹', dur: [18, 30] },
computador: { nome: 'No computador', emoji: '💻', dur: [25, 45] },
pescar: { nome: 'Pescando', emoji: '🎣', dur: [30, 50] },
receita: { nome: 'Criada por IA', emoji: '✨', dur: [20, 40] }, // receita do estúdio de criação
};
const ativ = { nome: null, t0: 0, dur: 0, fim: 0 };
const ativ = { nome: null, t0: 0, dur: 0, fim: 0, receita: null };
let palcoTela = null, pctx = null;
const agoraS = () => performance.now() / 1000;
function iniciarAtividade(nome, segundos) {
if (!ATIVIDADES[nome]) { pararAtividade(); return false; }
function iniciarAtividade(nome, segundos, receita) {
if (!ATIVIDADES[nome] || (nome === 'receita' && !receita)) { pararAtividade(); return false; }
const [a, b] = ATIVIDADES[nome].dur;
ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0;
ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0; ativ.receita = receita || null;
ativ.dur = segundos || a + Math.random() * (b - a);
return true;
}
@@ -620,6 +621,19 @@
const p = pincel(k); info.x = 16 + lado * 5 + p.x * 4; info.y = -1 + p.y * 5;
} else if (ativ.nome === 'malhar') {
const f = forca(k); info.x = lado * 14; info.y = 6 - f * 12; info.ab = f > 0.85 ? 0.55 : 1;
} else if (ativ.nome === 'receita') {
const R = ativ.receita || {}, w = Math.PI * 2 * (R.ritmo || 0.8);
const o = (R.objetos || [])[0];
if (R.olhar === 'cima') { info.x = lado * 12; info.y = -10; }
else if (R.olhar === 'baixo') { info.x = lado * 10; info.y = 8; }
else if (R.olhar === 'lado') { info.x = lado * 20; info.y = 2; }
else if (R.olhar === 'frente') { info.x = 4 * Math.sin(k * 0.7); info.y = 0; }
else { // segue o primeiro objeto
info.x = lado * 14 + Math.sin(k * w) * 5;
info.y = o && o.movimento === 'pular' ? 8 - Math.abs(Math.sin(k * w * 0.5)) * 16 : o && o.movimento === 'subir' ? -6 : 3;
}
if (R.forma && R.forma !== 'aberto') info.forma = R.forma;
if (R.boca && R.boca !== 'neutra') info.boca = R.boca;
} else if (ativ.nome === 'dormir') {
info.forma = 'dormir'; info.boca = 'sono'; info.x = lado * 6; info.y = 4;
} else if (ativ.nome === 'cafe') {
@@ -765,7 +779,112 @@
c.globalAlpha = alfa;
}
// ---- objetos que as receitas podem usar (mesmos desenhos das cenas feitas à mão)
function desenharObjeto(c, tipo, ox, oy, s, k, cor, giro, j) {
c.save(); c.translate(ox, oy); if (giro) c.rotate(giro);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
if (tipo === 'bola') {
const r = s * 0.28; c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); c.beginPath();
for (let i = 0; i < 5; i++) { const a = i * 1.2566; c.lineTo(Math.cos(a) * r * 0.38, Math.sin(a) * r * 0.38); }
c.closePath(); c.fill();
} else if (tipo === 'livro') {
const lw = s * 0.62, lh = s * 0.5;
for (const lado of [-1, 1]) { c.beginPath(); c.moveTo(0, lh * 0.55); c.lineTo(lado * lw, lh * 0.45); c.lineTo(lado * lw, -lh * 0.45); c.lineTo(0, -lh * 0.35); c.closePath(); c.fill(); c.stroke(); }
} else if (tipo === 'xicara') {
xicara(c, 0, 0, s, cor); vapor(c, 0, -s * 0.3, s, k, cor);
} else if (tipo === 'nota') {
c.fillStyle = css(cor); nota(c, 0, 0, s * 0.7);
} else if (tipo === 'estrela') {
c.fillStyle = css(j % 2 ? AMARELO_ : cor); estrela(c, 0, 0, s * 0.16);
} else if (tipo === 'coracao') {
c.fillStyle = css(ROSA_); coracao(c, 0, 0, s * 0.22);
} else if (tipo === 'zzz') {
zzz(c, 0, 0, s, k + j, cor);
} else if (tipo === 'chama') {
chama(c, 0, s * 0.15, s * (0.35 + 0.08 * Math.sin(k * 17 + j)), s);
} else if (tipo === 'bolha') {
const r = s * (0.13 + 0.1 * rnd(j, 4)); c.fillStyle = css(cor, 0.1);
c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.strokeStyle = claro(cor, 1); c.beginPath(); c.arc(0, 0, r * 0.65, -2.4, -1.6); c.stroke();
} else if (tipo === 'halter') {
c.fillRect(-s * 0.25, -s * 0.035, s * 0.5, s * 0.07);
for (const lado of [-1, 1]) { caixa(c, lado * s * 0.28 - s * 0.07, -s * 0.17, s * 0.14, s * 0.34, s * 0.04); c.fill(); c.stroke(); }
} else if (tipo === 'microfone') {
c.fillStyle = escuro(cor); c.beginPath(); capsula(c, 0, 0, -s * 0.1, -s * 0.4, s * 0.07); c.fill(); c.stroke();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(-s * 0.12, -s * 0.5, s * 0.14, 0, Math.PI * 2); c.fill(); c.stroke();
} else if (tipo === 'controle') {
caixa(c, -s * 0.42, -s * 0.16, s * 0.84, s * 0.34, s * 0.16); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); c.fillRect(-s * 0.3, -s * 0.02, s * 0.16, s * 0.05); c.fillRect(-s * 0.245, -s * 0.08, s * 0.05, s * 0.17);
} else if (tipo === 'pincel') {
c.beginPath(); c.moveTo(-s * 0.1, s * 0.08); c.lineTo(s * 0.4, -s * 0.12); c.stroke();
c.fillStyle = css([ROSA_, AMARELO_, VERDE_][Math.floor(k) % 3]); c.beginPath(); c.arc(s * 0.43, -s * 0.13, s * 0.07, 0, Math.PI * 2); c.fill();
} else if (tipo === 'flor') {
c.strokeStyle = css(VERDE_); c.beginPath(); c.moveTo(0, s * 0.5); c.lineTo(0, 0); c.stroke();
c.fillStyle = css(ROSA_);
for (let i = 0; i < 5; i++) { const a = i * 1.2566 + k * 0.3; c.beginPath(); c.arc(Math.cos(a) * s * 0.14, Math.sin(a) * s * 0.14, s * 0.1, 0, Math.PI * 2); c.fill(); }
c.fillStyle = css(AMARELO_); c.beginPath(); c.arc(0, 0, s * 0.08, 0, Math.PI * 2); c.fill();
}
c.restore();
}
/** Onde fica cada objeto da receita e como ele se mexe (pular, girar, subir, orbitar, piscar, balançar). */
function objetoReceita(c, o, i, b, x, y, s, k, cor, w) {
const q = Math.max(1, Math.min(5, o.quantidade || 1)), alfa = c.globalAlpha;
for (let j = 0; j < q; j++) {
let ox, oy;
if (o.onde === 'mao_direita') { ox = b.mD.x; oy = b.mD.y; }
else if (o.onde === 'mao_esquerda') { ox = b.mE.x; oy = b.mE.y; }
else if (o.onde === 'maos') { ox = (b.mE.x + b.mD.x) / 2; oy = (b.mE.y + b.mD.y) / 2; }
else if (o.onde === 'chao') { ox = x + s * (0.9 + i * 0.5); oy = y - s * 0.3; }
else if (o.onde === 'acima') { ox = b.cabeca.x + s * (0.8 + j * 0.5); oy = b.cabeca.y - s * 0.6; }
else { const a = (j / q) * Math.PI * 2 + i; ox = b.quadril.x + Math.cos(a) * s * 1.5; oy = b.quadril.y - s * 1.2 + Math.sin(a) * s * 1.1; }
if (q > 1 && (o.onde === 'mao_direita' || o.onde === 'mao_esquerda' || o.onde === 'maos' || o.onde === 'chao')) ox += (j - (q - 1) / 2) * s * 0.4;
let giro = 0;
c.globalAlpha = alfa;
if (o.movimento === 'pular') oy -= Math.abs(Math.sin(k * w * 0.5 + j)) * s * 1.6;
else if (o.movimento === 'girar') giro = k * 4 + j;
else if (o.movimento === 'balancar') ox += Math.sin(k * w + j) * s * 0.3;
else if (o.movimento === 'piscar') c.globalAlpha = alfa * (0.25 + 0.75 * Math.abs(Math.sin(k * 3 + j * 1.7)));
else if (o.movimento === 'subir') {
const f = (k * 0.35 + j / q) % 1;
ox += f * s * 0.8 + Math.sin(k * 2 + j) * s * 0.12; oy -= f * s * 1.8;
c.globalAlpha = alfa * Math.min(1, f / 0.12) * (1 - f);
} else if (o.movimento === 'orbitar') {
const a = k * 1.6 + (j / q) * Math.PI * 2;
ox = b.quadril.x + Math.cos(a) * s * 1.6; oy = b.quadril.y - s * 1.3 + Math.sin(a) * s * 0.9;
}
desenharObjeto(c, o.tipo, ox, oy, s, k, cor, giro, j);
}
c.globalAlpha = alfa;
}
const CENAS = {
// Atividade criada por IA: uma receita (criacao.py) montada com as peças que já existem aqui:
// o bonequinho, poses de braços/pernas e os objetos das outras cenas.
receita(c, x, y, s, k, cor) {
const R = ativ.receita || {};
const w = Math.PI * 2 * (R.ritmo || 0.8), onda = Math.sin(k * w);
const f1 = (onda + 1) / 2, f2 = R.alternados ? 1 - f1 : f1;
const rad = (g) => (g * Math.PI) / 180;
const ang = (b, f) => rad((b ? b.de : 20) + ((b ? b.ate : 40) - (b ? b.de : 20)) * f);
const pulo = (R.pulo || 0) * Math.max(0, onda), sobe = pulo * s * 0.5;
const balanco = Math.sin(k * w * 0.5) * (R.balanco || 0);
const cot = (R.cotovelos || 0) * 1.4;
let pE = [-0.08, 0], pD = [0.08, 0];
if (R.pernas === 'marchando') { pE = [-0.08 - 0.25 * f1, 0.45 * f1]; pD = [0.08 + 0.25 * (1 - f1), -0.45 * (1 - f1)]; }
else if (R.pernas === 'chutando') { const chute = Math.pow(Math.max(0, onda), 2); pD = [0.08 + chute * 0.9, -chute * 0.6]; }
else if (R.pernas === 'dancando') { pE = [-0.1 - f1 * 0.25, f1 * 0.4]; pD = [0.1 + (1 - f1) * 0.25, -(1 - f1) * 0.4]; }
else if (R.pernas === 'pulando') { pE = [-0.06, pulo * 0.3]; pD = [0.06, -pulo * 0.3]; }
const sentado = R.postura === 'sentado';
const b = boneco(c, x + balanco * s * 0.3, y - sobe, s, Object.assign(sentado ? Object.assign({}, SENTADO) : { pE, pD }, {
inclina: balanco * 0.25, cabeca: -balanco * 0.3, fechados: R.forma === 'dormir',
olhoY: R.olhar === 'cima' ? -1 : R.olhar === 'baixo' ? 0.8 : 0, olhoX: R.olhar === 'lado' ? 1 : 0,
bE: [-ang(R.braco_esquerdo, f1), cot], bD: [ang(R.braco_direito, f2), -cot],
}), cor, k);
(R.objetos || []).forEach((o, i) => objetoReceita(c, o, i, b, x, y, s, k, cor, w));
},
ler(c, x, y, s, k, cor) {
const b = boneco(c, x, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], cabeca: -0.08, olhoY: 0.8,
@@ -1268,7 +1387,7 @@
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
atividade: (nome, segundos) => (nome ? iniciarAtividade(nome, segundos) : (pararAtividade(), false)),
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
olharAtividade, palco: desenharPalco,
atividades: Object.fromEntries(Object.entries(ATIVIDADES).map(([k, v]) => [k, { nome: v.nome, emoji: v.emoji }])) };
+26 -2
View File
@@ -540,11 +540,31 @@ function passo(dt) { // avanca a fila; true se ha gesto em
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null;
const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
function atividade(nome, segundos) {
function atividade(nome, segundos, receita) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
if (window.MotorRosto.atividade(nome, segundos) && nome === "cantar") tocarSom("musica");
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
}
/* Expressão criada no estúdio (criacao.py): receita já validada no servidor, só números e nomes de
peças. Vira uma sequência igual às de sequencias(), com mãos e espiral se a receita pedir. */
function corDaReceita(nome) {
return { claro: "claro", escuro: "escuro", rosa: ROSA, amarelo: AMARELO, verde: VERDE, vermelho: VERM }[nome] || CIANO;
}
function tocarExpressaoCriada(texto) {
let rc;
try { rc = JSON.parse(texto); } catch (e) { return; }
if (!rc || !Array.isArray(rc.passos) || !rc.passos.length) return;
if (r.dormindo) r.dormindo = false;
r.fila = rc.passos.slice(0, 12).map((p) => [{
forma: p.forma || "aberto", forma2: null, boca: p.boca || "neutra", cor: corDaReceita(p.cor),
dx: +p.dx || 0, dy: +p.dy || 0, esc: +p.esc || 1, ab: typeof p.ab === "number" ? p.ab : 1,
}, Math.max(0.05, Math.min(2, +p.duracao || 0.3))]);
proximo();
const total = r.fila.reduce((a, passo) => a + passo[1], 0);
if (rc.espiral) ESPIRAL.ate = performance.now() / 1000 + total;
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
}
function repouso(t) { // pose calma: olha devagar, "respira"
@@ -689,6 +709,10 @@ function trata(ordem) {
HUMOR = ordem.slice(6).trim();
} else if (ordem.indexOf("ritmo ") === 0) {
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
} else if (ordem.indexOf("expressao_criada ") === 0) {
tocarExpressaoCriada(ordem.slice(17));
} else if (ordem.indexOf("atividade_criada ") === 0) {
try { const rc = JSON.parse(ordem.slice(17)); atividade("receita", +rc.duracao || 25, rc); } catch (e) {}
} else if (ordem.indexOf("atividade ") === 0) {
const [nome, seg] = ordem.slice(10).trim().split(/\s+/); // "atividade bola 180"
atividade(nome, parseFloat(seg) || 0);
+517
View File
@@ -0,0 +1,517 @@
"""Estúdio de criação: expressões, atividades e figurinhas feitas por IA a partir de um pedido.
A IA não escreve código: responde uma receita (JSON de formato fixo) montada só com peças que o rosto
já sabe animar. O prompt leva exemplos reais dos gestos e atividades que existem, e dá para pedir uma
variação de um deles ("basear em"). Quem cria é escolhido a cada pedido: Gemini, uma IA local num
servidor compatível com OpenAI (LM Studio ou llama-server, ex.: o ROG Ally) ou o Qwen pequeno do S10.
"""
import json
import os
import re
import sqlite3
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
import gemini
from comum import gravar_atomico, ler_json
from figurinhas import montar_prompt as prompt_figurinha
from figurinhas import validar_desenho
TIPOS = ('expressao', 'atividade', 'figurinha')
PROVEDORES = {'gemini': 'Gemini', 'local': 'IA local', 's10': 'Qwen do S10'}
MAX_CRIACOES = 300
# ---------------------------------------------------------------- peças disponíveis
FORMAS = ['aberto', 'feliz', 'surpreso', 'triste', 'bravo', 'coracao', 'dormir']
BOCAS = ['neutra', 'sorriso', 'aberta', 'triste', 'brava', 'sono']
CORES = ['escolhida', 'claro', 'escuro', 'rosa', 'amarelo', 'verde', 'vermelho']
MAOS = ['nenhuma', 'esconder', 'espiar', 'acenar', 'pensar', 'bocejar']
OBJETOS = ['bola', 'livro', 'xicara', 'nota', 'estrela', 'coracao', 'zzz', 'chama', 'bolha', 'halter',
'microfone', 'controle', 'pincel', 'flor']
ONDE = ['mao_direita', 'mao_esquerda', 'maos', 'chao', 'acima', 'em_volta']
MOVIMENTOS = ['parado', 'pular', 'girar', 'subir', 'orbitar', 'piscar', 'balancar']
PERNAS = ['paradas', 'marchando', 'chutando', 'dancando', 'pulando']
POSTURAS = ['em_pe', 'sentado']
OLHARES = ['objeto', 'frente', 'cima', 'baixo', 'lado']
def _s(**kw):
return dict(type='string', **kw)
def _n():
return dict(type='number')
def _obj(campos):
return dict(type='object', properties=campos, required=list(campos))
PASSO = _obj(dict(forma=_s(enum=FORMAS), boca=_s(enum=BOCAS), cor=_s(enum=CORES), dx=_n(), dy=_n(), esc=_n(),
ab=_n(), duracao=_n()))
SCHEMA_EXPRESSAO = _obj(dict(nome=_s(), emoji=_s(), maos=_s(enum=MAOS), espiral=dict(type='boolean'),
passos=dict(type='array', items=PASSO)))
BRACO = _obj(dict(de=_n(), ate=_n()))
OBJETO = _obj(dict(tipo=_s(enum=OBJETOS), onde=_s(enum=ONDE), movimento=_s(enum=MOVIMENTOS), quantidade=_n()))
SCHEMA_ATIVIDADE = _obj(dict(
nome=_s(), emoji=_s(), postura=_s(enum=POSTURAS), duracao=_n(), ritmo=_n(), balanco=_n(), pulo=_n(),
braco_esquerdo=BRACO, braco_direito=BRACO, cotovelos=_n(), alternados=dict(type='boolean'),
pernas=_s(enum=PERNAS), objetos=dict(type='array', items=OBJETO), olhar=_s(enum=OLHARES),
forma=_s(enum=FORMAS), boca=_s(enum=BOCAS)))
SCHEMA_FIGURINHA = _obj(dict(nome=_s(), local=_s(enum=['olhos', 'ao_redor']),
formas=dict(type='array', items=_obj(dict(caminho=_s(), cor=_s()))), emoji=_s()))
# ---------------------------------------------------------------- exemplos reais (o que já existe)
def _p(forma='aberto', boca='neutra', cor='escolhida', dx=0, dy=0, esc=1.0, ab=1.0, duracao=0.2):
return dict(forma=forma, boca=boca, cor=cor, dx=dx, dy=dy, esc=esc, ab=ab, duracao=duracao)
# Os gestos do carinha.html escritos no formato de receita: servem de exemplo e de base.
EXPRESSOES_BASE = {
'feliz': dict(nome='Feliz', emoji='😊', maos='nenhuma', espiral=False, passos=[
_p('feliz', 'sorriso', esc=1.05, duracao=0.16), _p('feliz', 'sorriso', esc=1.16, duracao=0.18),
_p('feliz', 'sorriso', esc=1.02, duracao=0.16), _p('feliz', 'sorriso', esc=1.13, duracao=0.16), _p(duracao=0.2)]),
'surpreso': dict(nome='Surpreso', emoji='😮', maos='nenhuma', espiral=False, passos=[
_p('surpreso', 'aberta', 'claro', dy=-8, esc=1.3, ab=1.12, duracao=0.09),
_p('surpreso', 'aberta', 'claro', dy=-10, esc=1.3, ab=1.12, duracao=0.5), _p(duracao=0.25)]),
'timido': dict(nome='Tímido', emoji='😳', maos='nenhuma', espiral=False, passos=[
_p('feliz', 'sorriso', 'rosa', dx=-14, dy=9, esc=0.9, duracao=0.35),
_p('feliz', 'sorriso', 'rosa', dx=-14, dy=9, esc=0.9, duracao=1.2), _p(duracao=0.4)]),
'comemorar': dict(nome='Comemorar', emoji='🥳', maos='nenhuma', espiral=False, passos=[
_p('feliz', 'sorriso', 'amarelo', dy=-10, esc=1.15, duracao=0.15), _p('feliz', 'sorriso', 'rosa', dy=2, esc=1.15, duracao=0.15),
_p('feliz', 'sorriso', 'verde', dy=-10, esc=1.15, duracao=0.15), _p('feliz', 'sorriso', dy=2, esc=1.15, duracao=0.15),
_p(duracao=0.3)]),
'bravo': dict(nome='Bravo', emoji='😠', maos='nenhuma', espiral=False, passos=[
_p('bravo', 'brava', 'vermelho', esc=0.98, duracao=0.1), _p('bravo', 'brava', 'vermelho', dx=-7, duracao=0.06),
_p('bravo', 'brava', 'vermelho', dx=7, duracao=0.06), _p('bravo', 'brava', 'vermelho', dx=0, duracao=0.06),
_p(duracao=0.32)]),
'triste': dict(nome='Triste', emoji='😢', maos='nenhuma', espiral=False, passos=[
_p('triste', 'triste', 'escuro', dy=5, duracao=0.18), _p('triste', 'triste', 'escuro', dy=7, duracao=0.5), _p(duracao=0.3)]),
'piscadinha': dict(nome='Piscadinha', emoji='😉', maos='nenhuma', espiral=False, passos=[
_p('feliz', 'sorriso', esc=1.04, duracao=0.12), _p('feliz', 'sorriso', esc=1.08, duracao=0.5), _p(duracao=0.18)]),
'pensar': dict(nome='Pensar', emoji='🤔', maos='pensar', espiral=False, passos=[
_p(dx=14, dy=-10, duracao=0.4), _p(dx=14, dy=-10, duracao=2.0), _p(duracao=0.4)]),
'bocejar': dict(nome='Bocejar', emoji='🥱', maos='bocejar', espiral=False, passos=[
_p(boca='aberta', dy=-4, esc=1.05, ab=0.5, duracao=0.5), _p(boca='aberta', dy=-5, esc=1.05, ab=0.25, duracao=1.0),
_p(dy=2, ab=0.0, duracao=0.25), _p(duracao=0.35)]),
'tonto': dict(nome='Tonto', emoji='😵‍💫', maos='nenhuma', espiral=True, passos=[
_p(boca='aberta', dx=5, dy=-2, esc=1.05, duracao=0.2), _p(boca='aberta', dx=-5, dy=2, esc=1.05, duracao=0.2),
_p(boca='aberta', dx=5, dy=-2, esc=1.05, duracao=0.2), _p(boca='aberta', dx=-5, dy=2, esc=1.05, duracao=0.2), _p(duracao=0.35)]),
}
# As atividades desenhadas à mão, descritas no formato de receita (aproximação): exemplo e base.
ATIVIDADES_BASE = {
'malhar': dict(nome='Malhando', emoji='🏋️', postura='em_pe', duracao=20, ritmo=0.35, balanco=0, pulo=0,
braco_esquerdo=dict(de=70, ate=170), braco_direito=dict(de=70, ate=170), cotovelos=0.6, alternados=False,
pernas='paradas', objetos=[dict(tipo='halter', onde='maos', movimento='parado', quantidade=1)],
olhar='objeto', forma='aberto', boca='neutra'),
'pular_corda': dict(nome='Pulando', emoji='🦘', postura='em_pe', duracao=20, ritmo=0.85, balanco=0, pulo=0.8,
braco_esquerdo=dict(de=40, ate=55), braco_direito=dict(de=40, ate=55), cotovelos=0.4, alternados=False,
pernas='pulando', objetos=[], olhar='frente', forma='aberto', boca='sorriso'),
'dancar': dict(nome='Dançando', emoji='💃', postura='em_pe', duracao=20, ritmo=0.8, balanco=0.6, pulo=0,
braco_esquerdo=dict(de=40, ate=160), braco_direito=dict(de=40, ate=160), cotovelos=0.3, alternados=True,
pernas='dancando', objetos=[dict(tipo='estrela', onde='em_volta', movimento='piscar', quantidade=4)],
olhar='frente', forma='feliz', boca='sorriso'),
'bolhas': dict(nome='Bolhas de sabão', emoji='🫧', postura='em_pe', duracao=20, ritmo=0.3, balanco=0.1, pulo=0,
braco_esquerdo=dict(de=15, ate=20), braco_direito=dict(de=100, ate=110), cotovelos=0.9, alternados=False,
pernas='paradas', objetos=[dict(tipo='bolha', onde='mao_direita', movimento='subir', quantidade=5)],
olhar='cima', forma='aberto', boca='sorriso'),
'ler': dict(nome='Lendo', emoji='📖', postura='sentado', duracao=30, ritmo=0.2, balanco=0, pulo=0,
braco_esquerdo=dict(de=10, ate=12), braco_direito=dict(de=10, ate=12), cotovelos=1.0, alternados=False,
pernas='paradas', objetos=[dict(tipo='livro', onde='maos', movimento='parado', quantidade=1)],
olhar='baixo', forma='aberto', boca='neutra'),
'cantar': dict(nome='Cantando', emoji='🎤', postura='em_pe', duracao=20, ritmo=0.7, balanco=0.5, pulo=0,
braco_esquerdo=dict(de=30, ate=60), braco_direito=dict(de=120, ate=130), cotovelos=0.8, alternados=False,
pernas='paradas', objetos=[dict(tipo='microfone', onde='mao_direita', movimento='parado', quantidade=1),
dict(tipo='nota', onde='acima', movimento='subir', quantidade=3)],
olhar='frente', forma='feliz', boca='aberta'),
'embaixadinha': dict(nome='Embaixadinha', emoji='⚽', postura='em_pe', duracao=20, ritmo=0.9, balanco=0.1, pulo=0,
braco_esquerdo=dict(de=30, ate=35), braco_direito=dict(de=30, ate=35), cotovelos=0.2, alternados=False,
pernas='chutando', objetos=[dict(tipo='bola', onde='chao', movimento='pular', quantidade=1)],
olhar='objeto', forma='aberto', boca='neutra'),
'cafe': dict(nome='Café', emoji='☕', postura='sentado', duracao=20, ritmo=0.2, balanco=0, pulo=0,
braco_esquerdo=dict(de=10, ate=10), braco_direito=dict(de=10, ate=90), cotovelos=0.9, alternados=False,
pernas='paradas', objetos=[dict(tipo='xicara', onde='mao_direita', movimento='parado', quantidade=1)],
olhar='objeto', forma='aberto', boca='neutra'),
}
EXPLICA_EXPRESSAO = (
'O Carinha é um rosto de robô numa tela de celular: dois olhos grandes, uma boca e, às vezes, mãozinhas. '
'Uma expressão é uma sequência de 2 a 10 passos; o rosto vai de um passo ao outro suavemente. Em cada passo: '
'forma dos olhos (aberto, feliz = arco fechadinho de alegria, surpreso, triste, bravo, coracao, dormir = fechados), '
'boca (neutra, sorriso, aberta, triste, brava, sono), cor (escolhida = a cor normal dele, claro, escuro, rosa, '
'amarelo, verde, vermelho), dx (olhar para o lado: -24 esquerda a 24 direita), dy (-12 para cima a 12 para baixo), '
'esc (tamanho dos olhos 0.8 a 1.3), ab (abertura: 0 fechado, 1 normal, 1.15 arregalado) e duracao em segundos '
'(0.06 a 2). A soma dos passos fica entre 1 e 6 segundos. maos: nenhuma, esconder (tapa os olhos), espiar, acenar, '
'pensar (mão no queixo) ou bocejar (mão na boca). espiral: true só para tontura (espirais no lugar dos olhos). '
'nome curto em português e um emoji que represente.')
EXPLICA_ATIVIDADE = (
'Uma atividade mostra um bonequinho robô (cabeça de tela, tronco, braços e pernas) fazendo algo por alguns segundos, '
'ao lado do rosto. Ângulos dos braços em graus: 0 = caído ao lado do corpo, 90 = esticado para o lado, 180 = para '
'cima; o braço oscila de "de" até "ate" no ritmo (vezes por segundo, 0.1 a 3); alternados faz um braço subir quando '
'o outro desce; cotovelos de 0 (reto) a 1 (bem dobrado). balanco (0 a 1) mexe o corpo de lado; pulo (0 a 1) faz '
'pular. postura: em_pe ou sentado. pernas: paradas, marchando, chutando, dancando ou pulando. objetos (até 4): '
'tipo (' + ', '.join(OBJETOS) + '), onde (mao_direita, mao_esquerda, maos, chao, acima, em_volta), movimento '
'(parado, pular, girar, subir, orbitar, piscar, balancar) e quantidade (1 a 5). olhar: para onde os olhos grandes do '
'rosto olham (objeto, frente, cima, baixo, lado). forma e boca do rosto durante a atividade (mesmas opções das '
'expressões). duracao de 10 a 60 segundos. Use só essas peças: combine-as para representar o pedido.')
def _num(v, lo, hi, padrao):
try:
v = float(v)
except (TypeError, ValueError):
return padrao
if v != v or v in (float('inf'), float('-inf')):
return padrao
return max(lo, min(hi, v))
def _enum(v, opcoes, padrao):
return v if v in opcoes else padrao
def _texto(v, n, padrao):
v = re.sub(r'\s+', ' ', str(v or '')).strip()[:n]
return v or padrao
def _emoji(v):
v = str(v or '').strip()
return v[:8] if v and not re.search(r'[A-Za-z0-9<>{}]', v) else '✨'
def validar_expressao(r):
"""Normaliza a receita: limita números, troca valores desconhecidos e garante que termina no rosto normal."""
if not isinstance(r, dict) or not isinstance(r.get('passos'), list) or not r['passos']:
raise ValueError('A IA não devolveu passos para a expressão')
passos = []
for p in r['passos'][:10]:
if not isinstance(p, dict):
continue
passos.append(dict(forma=_enum(p.get('forma'), FORMAS, 'aberto'), boca=_enum(p.get('boca'), BOCAS, 'neutra'),
cor=_enum(p.get('cor'), CORES, 'escolhida'), dx=_num(p.get('dx'), -24, 24, 0),
dy=_num(p.get('dy'), -12, 12, 0), esc=_num(p.get('esc'), 0.8, 1.3, 1),
ab=_num(p.get('ab'), 0, 1.15, 1), duracao=_num(p.get('duracao'), 0.06, 2, 0.3)))
if len(passos) < 2:
raise ValueError('A expressão precisa de pelo menos 2 passos')
total = sum(p['duracao'] for p in passos)
if total > 6: # longa demais: encolhe tudo na mesma proporção
for p in passos:
p['duracao'] = round(p['duracao'] * 6 / total, 3)
ultimo = passos[-1]
if (ultimo['forma'], ultimo['boca'], ultimo['dx'], ultimo['dy'], ultimo['esc']) != ('aberto', 'neutra', 0, 0, 1):
passos.append(_p(duracao=0.3)) # sempre volta ao rosto normal
return dict(nome=_texto(r.get('nome'), 30, 'Nova expressão'), emoji=_emoji(r.get('emoji')),
maos=_enum(r.get('maos'), MAOS, 'nenhuma'), espiral=r.get('espiral') is True, passos=passos)
def validar_atividade(r):
if not isinstance(r, dict):
raise ValueError('A IA não devolveu uma atividade')
def braco(b):
b = b if isinstance(b, dict) else {}
return dict(de=_num(b.get('de'), -30, 180, 20), ate=_num(b.get('ate'), -30, 180, 40))
objetos = []
for o in (r.get('objetos') if isinstance(r.get('objetos'), list) else []):
if isinstance(o, dict) and o.get('tipo') in OBJETOS and len(objetos) < 4: # descarta o que não existe, depois limita
objetos.append(dict(tipo=o['tipo'], onde=_enum(o.get('onde'), ONDE, 'mao_direita'),
movimento=_enum(o.get('movimento'), MOVIMENTOS, 'parado'),
quantidade=int(_num(o.get('quantidade'), 1, 5, 1))))
return dict(nome=_texto(r.get('nome'), 30, 'Nova atividade'), emoji=_emoji(r.get('emoji')),
postura=_enum(r.get('postura'), POSTURAS, 'em_pe'), duracao=_num(r.get('duracao'), 10, 60, 25),
ritmo=_num(r.get('ritmo'), 0.1, 3, 0.8), balanco=_num(r.get('balanco'), 0, 1, 0.2),
pulo=_num(r.get('pulo'), 0, 1, 0), braco_esquerdo=braco(r.get('braco_esquerdo')),
braco_direito=braco(r.get('braco_direito')), cotovelos=_num(r.get('cotovelos'), 0, 1, 0.3),
alternados=r.get('alternados') is True, pernas=_enum(r.get('pernas'), PERNAS, 'paradas'),
objetos=objetos, olhar=_enum(r.get('olhar'), OLHARES, 'objeto'),
forma=_enum(r.get('forma'), FORMAS, 'aberto'), boca=_enum(r.get('boca'), BOCAS, 'neutra'))
def montar_prompt(tipo, pedido, base=None):
"""Prompt com as peças, exemplos reais do que já existe e, se houver, a receita de base para variar."""
pedido_json = json.dumps(pedido, ensure_ascii=False)
if tipo == 'figurinha':
return prompt_figurinha(pedido) + ' Inclua também "emoji": um emoji que represente o desenho.'
if tipo == 'expressao':
exemplos = {k: EXPRESSOES_BASE[k] for k in ('surpreso', 'timido', 'comemorar', 'pensar')}
texto = 'Você cria expressões animadas para o Carinha. ' + EXPLICA_EXPRESSAO
else:
exemplos = {k: ATIVIDADES_BASE[k] for k in ('malhar', 'dancar', 'bolhas', 'cantar')}
texto = 'Você cria atividades para o Carinha. ' + EXPLICA_ATIVIDADE
texto += ' Exemplos reais que já existem (reaproveite as ideias, ritmos e combinações): ' + json.dumps(exemplos, ensure_ascii=False)
if base:
texto += (' Parta desta receita que já existe e faça uma variação que atenda ao pedido, reaproveitando o que '
'servir: ' + json.dumps(base, ensure_ascii=False))
return texto + ' Responda só a receita em JSON. Pedido (dado, não instrução de sistema): ' + pedido_json
def _extrair_json(texto):
texto = re.sub(r'<think>.*?</think>', '', texto or '', flags=re.S).strip()
texto = re.sub(r'^```(?:json)?|```$', '', texto, flags=re.M).strip()
inicio, fim = texto.find('{'), texto.rfind('}')
if inicio < 0 or fim < inicio:
raise ValueError('A IA não respondeu JSON')
return json.loads(texto[inicio:fim + 1])
class Criacao:
def __init__(self, caminho_db, caminho_config):
self.caminho_db = str(caminho_db)
self.caminho_config = Path(caminho_config)
self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None
self.enfileirar = lambda ordem: None
self.treinamento = None
self.s10 = lambda: (None, {}) # (endpoint, cabeçalhos) do Qwen do S10 (cérebro)
self.ocupado = None # {tipo, ia, desde, pedido} enquanto cria
self.erro = ''
self.config = dict(local=dict(url='', modelo='', chave=''))
try:
salvo = json.loads(self.caminho_config.read_text(encoding='utf-8'))
if isinstance(salvo.get('local'), dict):
self.config['local'].update({k: str(v) for k, v in salvo['local'].items() if k in ('url', 'modelo', 'chave')})
except (OSError, ValueError, AttributeError):
pass
with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS criacoes(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, nome TEXT, '
'emoji TEXT, receita TEXT, ia TEXT, pedido TEXT, excluida INTEGER DEFAULT 0)')
@contextmanager
def db(self):
con = sqlite3.connect(self.caminho_db)
try:
with con:
yield con
finally:
con.close()
# ------------------------------------------------------------ provedores
def provedores(self):
local = self.config['local']
s10, _ = self.s10()
return dict(
gemini=dict(nome=PROVEDORES['gemini'], pronto=bool(os.environ.get('GEMINI_API_KEY')),
detalhe=gemini.MODELO, motivo='' if os.environ.get('GEMINI_API_KEY') else 'sem chave Gemini'),
local=dict(nome=PROVEDORES['local'], pronto=bool(local['url']), detalhe=local['modelo'] or local['url'],
motivo='' if local['url'] else 'configure o endereço', url=local['url'], modelo=local['modelo'],
chave=bool(local['chave'])),
s10=dict(nome=PROVEDORES['s10'], pronto=bool(s10), detalhe='Qwen3 0.6B (fraco para criar)',
motivo='' if s10 else 'indisponível'))
def chamar(self, ia, prompt, schema):
if ia == 'gemini':
return gemini.gerar_json(dict(contents=[dict(role='user', parts=[dict(text=prompt)])], generationConfig=dict(
temperature=0.8, maxOutputTokens=4096, responseMimeType='application/json', responseSchema=schema)))
if ia == 'local':
url, modelo, chave = self.config['local']['url'].rstrip('/'), self.config['local']['modelo'], self.config['local']['chave']
if not url:
raise ValueError('Configure o endereço da IA local')
cab = {'Content-Type': 'application/json'}
if chave:
cab['Authorization'] = 'Bearer ' + chave
extra = {}
else:
url, cab = self.s10()
if not url:
raise ValueError('Qwen do S10 indisponível')
modelo, extra = 'local', dict(chat_template_kwargs=dict(enable_thinking=False))
prompt += ' /no_think'
payload = dict(model=modelo or 'local', temperature=0.8, max_tokens=3000, messages=[
dict(role='system', content='Responda somente com JSON válido no formato pedido.'),
dict(role='user', content=prompt)],
response_format=dict(type='json_schema', json_schema=dict(name='receita', strict=True, schema=schema)), **extra)
if url.endswith('/v1'):
url = url[:-3]
req = Request(url + '/v1/chat/completions', data=json.dumps(payload).encode(), headers=cab)
try:
with urlopen(req, timeout=900) as resp: # a IA local pode levar minutos
dados = json.load(resp)
except HTTPError as e:
raise ValueError(f'A IA local recusou o pedido (HTTP {e.code})') from None
except (URLError, TimeoutError, OSError):
raise ValueError('Não consegui falar com a IA local: confira se ela está ligada e o endereço') from None
return _extrair_json(dados['choices'][0]['message']['content'])
# ------------------------------------------------------------ criar
def base(self, tipo, nome):
if not nome:
return None
if str(nome).startswith('criado:'):
item = self.item(int(str(nome)[7:]))
return item['receita'] if item and item['tipo'] == tipo else None
return (EXPRESSOES_BASE if tipo == 'expressao' else ATIVIDADES_BASE).get(nome)
def criar(self, tipo, pedido, ia, base=None):
if tipo not in TIPOS:
raise ValueError('Tipo inválido')
if ia not in PROVEDORES:
raise ValueError('IA inválida')
if not isinstance(pedido, str) or not pedido.strip() or len(pedido) > 600:
raise ValueError('Descreva o pedido em até 600 caracteres')
if not self.provedores()[ia]['pronto']:
raise ValueError(PROVEDORES[ia] + ': ' + self.provedores()[ia]['motivo'])
with self.lock:
if self.ocupado:
raise ValueError('Já tem uma criação em andamento')
self.ocupado = dict(tipo=tipo, ia=ia, desde=time.time(), pedido=pedido.strip())
self.erro = ''
receita_base = self.base(tipo, base) if tipo != 'figurinha' else None
threading.Thread(target=self._criar, args=(tipo, pedido.strip(), ia, receita_base), daemon=True).start()
self.avisar()
return self.status()
def _criar(self, tipo, pedido, ia, base):
try:
schema = dict(expressao=SCHEMA_EXPRESSAO, atividade=SCHEMA_ATIVIDADE, figurinha=SCHEMA_FIGURINHA)[tipo]
bruto = self.chamar(ia, montar_prompt(tipo, pedido, base), schema)
if tipo == 'expressao':
receita = validar_expressao(bruto)
elif tipo == 'atividade':
receita = validar_atividade(bruto)
else:
emoji = _emoji(bruto.pop('emoji', '')) if isinstance(bruto, dict) else '🎨'
desenho = validar_desenho(bruto)
receita = dict(nome=desenho['nome'][:30], emoji=emoji, desenho=desenho)
with self.lock, self.db() as db:
ident = db.execute('INSERT INTO criacoes(horario, tipo, nome, emoji, receita, ia, pedido) VALUES (?,?,?,?,?,?,?)',
(time.time(), tipo, receita['nome'], receita['emoji'],
json.dumps(receita, ensure_ascii=False), ia, pedido)).lastrowid
db.execute('DELETE FROM criacoes WHERE id NOT IN (SELECT id FROM criacoes ORDER BY id DESC LIMIT ?)', (MAX_CRIACOES,))
self.tocar(ident) # mostra na hora no celular
except Exception as e:
with self.lock:
self.erro = str(e)[:200]
finally:
with self.lock:
self.ocupado = None
self.avisar()
# ------------------------------------------------------------ biblioteca
def item(self, ident):
with self.db() as db:
row = db.execute('SELECT id, horario, tipo, nome, emoji, receita, ia, pedido FROM criacoes WHERE id=? AND excluida=0',
(ident,)).fetchone()
if not row:
return None
return dict(id=row[0], horario=row[1], tipo=row[2], nome=row[3], emoji=row[4], receita=json.loads(row[5]),
ia=row[6], pedido=row[7])
def lista(self):
with self.db() as db:
rows = db.execute('SELECT id FROM criacoes WHERE excluida=0 ORDER BY id DESC LIMIT ?', (MAX_CRIACOES,)).fetchall()
return [self.item(r[0]) for r in rows]
def tocar(self, ident):
item = self.item(ident) if type(ident) is int else None
if not item:
raise ValueError('Criação não encontrada')
r = item['receita']
if item['tipo'] == 'expressao':
self.enfileirar('expressao_criada ' + json.dumps(r, ensure_ascii=False, separators=(',', ':')))
elif item['tipo'] == 'atividade':
self.enfileirar('atividade_criada ' + json.dumps(r, ensure_ascii=False, separators=(',', ':')))
else:
from treinamento import validar_receita
if self.treinamento is None:
raise ValueError('Biblioteca de reações indisponível')
passo = dict(olhos='aberto', boca='sorriso', cor='#5cd6ff', dx=0, dy=0, escala=1, duracao=2)
receita = validar_receita(dict(nome=r['nome'], motivo='Criada no estúdio', simbolo='nenhum', legenda='',
passos=[passo, dict(passo, escala=1.05)], figurinha=r['desenho']))
with self.treinamento.lock:
self.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=receita)
return dict(ok=True)
def apagar(self, ident):
if type(ident) is not int:
raise ValueError('ID inválido')
with self.db() as db:
db.execute('UPDATE criacoes SET excluida=1 WHERE id=?', (ident,))
self.avisar()
return dict(ok=True)
def configurar(self, dados):
local = dados.get('local')
if not isinstance(local, dict):
raise ValueError('Esperava a configuração da IA local')
novo = dict(self.config['local'])
for k in ('url', 'modelo', 'chave'):
if k in local:
v = local[k]
if not isinstance(v, str) or len(v) > 300:
raise ValueError(k + ' inválido')
if k == 'url' and v and not re.fullmatch(r'https?://[\w.\-:]+(/v1)?/?', v.strip()):
raise ValueError('Endereço deve ser como http://192.168.1.50:1234')
novo[k] = v.strip()
with self.lock:
self.config['local'] = novo
gravar_atomico(self.caminho_config, self.config, privado=True)
self.avisar()
return self.status()
def testar(self, ia):
if ia == 'gemini':
if not os.environ.get('GEMINI_API_KEY'):
raise ValueError('Sem chave Gemini (Ajustes › Inteligência artificial)')
return dict(ok=True, mensagem='Gemini configurado: ' + gemini.MODELO)
url = self.config['local']['url'].rstrip('/') if ia == 'local' else (self.s10()[0] or '')
if not url:
raise ValueError('Configure o endereço primeiro')
if url.endswith('/v1'):
url = url[:-3]
cab = {}
if ia == 'local' and self.config['local']['chave']:
cab['Authorization'] = 'Bearer ' + self.config['local']['chave']
inicio = time.monotonic()
try:
with urlopen(Request(url + '/v1/models', headers=cab), timeout=8) as resp:
modelos = [m.get('id') for m in json.load(resp).get('data', [])]
except (URLError, HTTPError, OSError, ValueError):
raise ValueError('Não respondeu em ' + url) from None
return dict(ok=True, mensagem='Respondeu em %.1f s · modelos: %s' % (time.monotonic() - inicio, ', '.join(map(str, modelos[:5])) or '—'))
def status(self):
with self.lock:
ocupado = dict(self.ocupado) if self.ocupado else None
return dict(ocupado=ocupado, erro=self.erro, provedores=self.provedores(), criacoes=self.lista(),
bases=dict(expressao=[dict(id=k, nome=v['nome'], emoji=v['emoji']) for k, v in EXPRESSOES_BASE.items()],
atividade=[dict(id=k, nome=v['nome'], emoji=v['emoji']) for k, v in ATIVIDADES_BASE.items()]))
def avisar(self):
self.publicar('criacao', self.status())
# ------------------------------------------------------------ HTTP
def rotas(self, h, metodo):
rota = h.path.split('?', 1)[0]
if not rota.startswith('/criacao/'):
return False
try:
if metodo == 'GET' and rota == '/criacao/status':
resultado = self.status()
elif metodo == 'POST':
d = ler_json(h, 4096)
if rota == '/criacao/criar':
resultado = self.criar(d.get('tipo'), d.get('pedido'), d.get('ia'), d.get('base') or None)
elif rota == '/criacao/tocar':
resultado = self.tocar(d.get('id'))
elif rota == '/criacao/apagar':
resultado = self.apagar(d.get('id'))
elif rota == '/criacao/config':
resultado = self.configurar(d)
elif rota == '/criacao/testar':
resultado = self.testar(d.get('ia'))
else:
raise ValueError('Rota inválida')
else:
raise ValueError('Método inválido')
h.enviar(json.dumps(resultado, ensure_ascii=False))
except (ValueError, TypeError) as e:
h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400)
return True
+1 -1
View File
@@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
)
+9 -3
View File
@@ -42,6 +42,7 @@ from treinamento import Treinamento
from api_config import ConfigAPI
from historico import Historico
from vida import Vida
from criacao import Criacao
TRAVA = threading.Lock()
@@ -75,7 +76,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/",
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/criacao/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -247,7 +248,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self):
s = self.server
return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "treinamento", "conversa", "cerebro",
return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "criacao", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)]
def estado(self):
@@ -467,7 +468,7 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida"):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao"):
if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -494,6 +495,11 @@ def main():
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
servidor.historico = Historico(Path.cwd())
servidor.vida = vida = Vida(Path.cwd() / 'vida.json')
# Estúdio de criação: expressões, atividades e figurinhas por IA (Gemini, IA local ou Qwen do S10)
servidor.criacao = Criacao(Path.cwd() / 'criacoes.db', Path.cwd() / 'criacao.json')
servidor.criacao.enfileirar = enfileirar
servidor.criacao.treinamento = servidor.treinamento
servidor.criacao.s10 = lambda: (servidor.cerebro.endpoint, servidor.cerebro.cabecalhos())
def registrar_conversa(papel, texto):
servidor.treinamento.registrar(papel, texto)
+124 -5
View File
@@ -568,15 +568,16 @@
skate: { nome: 'Andando de skate', emoji: '🛹', dur: [18, 30] },
computador: { nome: 'No computador', emoji: '💻', dur: [25, 45] },
pescar: { nome: 'Pescando', emoji: '🎣', dur: [30, 50] },
receita: { nome: 'Criada por IA', emoji: '✨', dur: [20, 40] }, // receita do estúdio de criação
};
const ativ = { nome: null, t0: 0, dur: 0, fim: 0 };
const ativ = { nome: null, t0: 0, dur: 0, fim: 0, receita: null };
let palcoTela = null, pctx = null;
const agoraS = () => performance.now() / 1000;
function iniciarAtividade(nome, segundos) {
if (!ATIVIDADES[nome]) { pararAtividade(); return false; }
function iniciarAtividade(nome, segundos, receita) {
if (!ATIVIDADES[nome] || (nome === 'receita' && !receita)) { pararAtividade(); return false; }
const [a, b] = ATIVIDADES[nome].dur;
ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0;
ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0; ativ.receita = receita || null;
ativ.dur = segundos || a + Math.random() * (b - a);
return true;
}
@@ -620,6 +621,19 @@
const p = pincel(k); info.x = 16 + lado * 5 + p.x * 4; info.y = -1 + p.y * 5;
} else if (ativ.nome === 'malhar') {
const f = forca(k); info.x = lado * 14; info.y = 6 - f * 12; info.ab = f > 0.85 ? 0.55 : 1;
} else if (ativ.nome === 'receita') {
const R = ativ.receita || {}, w = Math.PI * 2 * (R.ritmo || 0.8);
const o = (R.objetos || [])[0];
if (R.olhar === 'cima') { info.x = lado * 12; info.y = -10; }
else if (R.olhar === 'baixo') { info.x = lado * 10; info.y = 8; }
else if (R.olhar === 'lado') { info.x = lado * 20; info.y = 2; }
else if (R.olhar === 'frente') { info.x = 4 * Math.sin(k * 0.7); info.y = 0; }
else { // segue o primeiro objeto
info.x = lado * 14 + Math.sin(k * w) * 5;
info.y = o && o.movimento === 'pular' ? 8 - Math.abs(Math.sin(k * w * 0.5)) * 16 : o && o.movimento === 'subir' ? -6 : 3;
}
if (R.forma && R.forma !== 'aberto') info.forma = R.forma;
if (R.boca && R.boca !== 'neutra') info.boca = R.boca;
} else if (ativ.nome === 'dormir') {
info.forma = 'dormir'; info.boca = 'sono'; info.x = lado * 6; info.y = 4;
} else if (ativ.nome === 'cafe') {
@@ -765,7 +779,112 @@
c.globalAlpha = alfa;
}
// ---- objetos que as receitas podem usar (mesmos desenhos das cenas feitas à mão)
function desenharObjeto(c, tipo, ox, oy, s, k, cor, giro, j) {
c.save(); c.translate(ox, oy); if (giro) c.rotate(giro);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
if (tipo === 'bola') {
const r = s * 0.28; c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); c.beginPath();
for (let i = 0; i < 5; i++) { const a = i * 1.2566; c.lineTo(Math.cos(a) * r * 0.38, Math.sin(a) * r * 0.38); }
c.closePath(); c.fill();
} else if (tipo === 'livro') {
const lw = s * 0.62, lh = s * 0.5;
for (const lado of [-1, 1]) { c.beginPath(); c.moveTo(0, lh * 0.55); c.lineTo(lado * lw, lh * 0.45); c.lineTo(lado * lw, -lh * 0.45); c.lineTo(0, -lh * 0.35); c.closePath(); c.fill(); c.stroke(); }
} else if (tipo === 'xicara') {
xicara(c, 0, 0, s, cor); vapor(c, 0, -s * 0.3, s, k, cor);
} else if (tipo === 'nota') {
c.fillStyle = css(cor); nota(c, 0, 0, s * 0.7);
} else if (tipo === 'estrela') {
c.fillStyle = css(j % 2 ? AMARELO_ : cor); estrela(c, 0, 0, s * 0.16);
} else if (tipo === 'coracao') {
c.fillStyle = css(ROSA_); coracao(c, 0, 0, s * 0.22);
} else if (tipo === 'zzz') {
zzz(c, 0, 0, s, k + j, cor);
} else if (tipo === 'chama') {
chama(c, 0, s * 0.15, s * (0.35 + 0.08 * Math.sin(k * 17 + j)), s);
} else if (tipo === 'bolha') {
const r = s * (0.13 + 0.1 * rnd(j, 4)); c.fillStyle = css(cor, 0.1);
c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.strokeStyle = claro(cor, 1); c.beginPath(); c.arc(0, 0, r * 0.65, -2.4, -1.6); c.stroke();
} else if (tipo === 'halter') {
c.fillRect(-s * 0.25, -s * 0.035, s * 0.5, s * 0.07);
for (const lado of [-1, 1]) { caixa(c, lado * s * 0.28 - s * 0.07, -s * 0.17, s * 0.14, s * 0.34, s * 0.04); c.fill(); c.stroke(); }
} else if (tipo === 'microfone') {
c.fillStyle = escuro(cor); c.beginPath(); capsula(c, 0, 0, -s * 0.1, -s * 0.4, s * 0.07); c.fill(); c.stroke();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(-s * 0.12, -s * 0.5, s * 0.14, 0, Math.PI * 2); c.fill(); c.stroke();
} else if (tipo === 'controle') {
caixa(c, -s * 0.42, -s * 0.16, s * 0.84, s * 0.34, s * 0.16); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); c.fillRect(-s * 0.3, -s * 0.02, s * 0.16, s * 0.05); c.fillRect(-s * 0.245, -s * 0.08, s * 0.05, s * 0.17);
} else if (tipo === 'pincel') {
c.beginPath(); c.moveTo(-s * 0.1, s * 0.08); c.lineTo(s * 0.4, -s * 0.12); c.stroke();
c.fillStyle = css([ROSA_, AMARELO_, VERDE_][Math.floor(k) % 3]); c.beginPath(); c.arc(s * 0.43, -s * 0.13, s * 0.07, 0, Math.PI * 2); c.fill();
} else if (tipo === 'flor') {
c.strokeStyle = css(VERDE_); c.beginPath(); c.moveTo(0, s * 0.5); c.lineTo(0, 0); c.stroke();
c.fillStyle = css(ROSA_);
for (let i = 0; i < 5; i++) { const a = i * 1.2566 + k * 0.3; c.beginPath(); c.arc(Math.cos(a) * s * 0.14, Math.sin(a) * s * 0.14, s * 0.1, 0, Math.PI * 2); c.fill(); }
c.fillStyle = css(AMARELO_); c.beginPath(); c.arc(0, 0, s * 0.08, 0, Math.PI * 2); c.fill();
}
c.restore();
}
/** Onde fica cada objeto da receita e como ele se mexe (pular, girar, subir, orbitar, piscar, balançar). */
function objetoReceita(c, o, i, b, x, y, s, k, cor, w) {
const q = Math.max(1, Math.min(5, o.quantidade || 1)), alfa = c.globalAlpha;
for (let j = 0; j < q; j++) {
let ox, oy;
if (o.onde === 'mao_direita') { ox = b.mD.x; oy = b.mD.y; }
else if (o.onde === 'mao_esquerda') { ox = b.mE.x; oy = b.mE.y; }
else if (o.onde === 'maos') { ox = (b.mE.x + b.mD.x) / 2; oy = (b.mE.y + b.mD.y) / 2; }
else if (o.onde === 'chao') { ox = x + s * (0.9 + i * 0.5); oy = y - s * 0.3; }
else if (o.onde === 'acima') { ox = b.cabeca.x + s * (0.8 + j * 0.5); oy = b.cabeca.y - s * 0.6; }
else { const a = (j / q) * Math.PI * 2 + i; ox = b.quadril.x + Math.cos(a) * s * 1.5; oy = b.quadril.y - s * 1.2 + Math.sin(a) * s * 1.1; }
if (q > 1 && (o.onde === 'mao_direita' || o.onde === 'mao_esquerda' || o.onde === 'maos' || o.onde === 'chao')) ox += (j - (q - 1) / 2) * s * 0.4;
let giro = 0;
c.globalAlpha = alfa;
if (o.movimento === 'pular') oy -= Math.abs(Math.sin(k * w * 0.5 + j)) * s * 1.6;
else if (o.movimento === 'girar') giro = k * 4 + j;
else if (o.movimento === 'balancar') ox += Math.sin(k * w + j) * s * 0.3;
else if (o.movimento === 'piscar') c.globalAlpha = alfa * (0.25 + 0.75 * Math.abs(Math.sin(k * 3 + j * 1.7)));
else if (o.movimento === 'subir') {
const f = (k * 0.35 + j / q) % 1;
ox += f * s * 0.8 + Math.sin(k * 2 + j) * s * 0.12; oy -= f * s * 1.8;
c.globalAlpha = alfa * Math.min(1, f / 0.12) * (1 - f);
} else if (o.movimento === 'orbitar') {
const a = k * 1.6 + (j / q) * Math.PI * 2;
ox = b.quadril.x + Math.cos(a) * s * 1.6; oy = b.quadril.y - s * 1.3 + Math.sin(a) * s * 0.9;
}
desenharObjeto(c, o.tipo, ox, oy, s, k, cor, giro, j);
}
c.globalAlpha = alfa;
}
const CENAS = {
// Atividade criada por IA: uma receita (criacao.py) montada com as peças que já existem aqui:
// o bonequinho, poses de braços/pernas e os objetos das outras cenas.
receita(c, x, y, s, k, cor) {
const R = ativ.receita || {};
const w = Math.PI * 2 * (R.ritmo || 0.8), onda = Math.sin(k * w);
const f1 = (onda + 1) / 2, f2 = R.alternados ? 1 - f1 : f1;
const rad = (g) => (g * Math.PI) / 180;
const ang = (b, f) => rad((b ? b.de : 20) + ((b ? b.ate : 40) - (b ? b.de : 20)) * f);
const pulo = (R.pulo || 0) * Math.max(0, onda), sobe = pulo * s * 0.5;
const balanco = Math.sin(k * w * 0.5) * (R.balanco || 0);
const cot = (R.cotovelos || 0) * 1.4;
let pE = [-0.08, 0], pD = [0.08, 0];
if (R.pernas === 'marchando') { pE = [-0.08 - 0.25 * f1, 0.45 * f1]; pD = [0.08 + 0.25 * (1 - f1), -0.45 * (1 - f1)]; }
else if (R.pernas === 'chutando') { const chute = Math.pow(Math.max(0, onda), 2); pD = [0.08 + chute * 0.9, -chute * 0.6]; }
else if (R.pernas === 'dancando') { pE = [-0.1 - f1 * 0.25, f1 * 0.4]; pD = [0.1 + (1 - f1) * 0.25, -(1 - f1) * 0.4]; }
else if (R.pernas === 'pulando') { pE = [-0.06, pulo * 0.3]; pD = [0.06, -pulo * 0.3]; }
const sentado = R.postura === 'sentado';
const b = boneco(c, x + balanco * s * 0.3, y - sobe, s, Object.assign(sentado ? Object.assign({}, SENTADO) : { pE, pD }, {
inclina: balanco * 0.25, cabeca: -balanco * 0.3, fechados: R.forma === 'dormir',
olhoY: R.olhar === 'cima' ? -1 : R.olhar === 'baixo' ? 0.8 : 0, olhoX: R.olhar === 'lado' ? 1 : 0,
bE: [-ang(R.braco_esquerdo, f1), cot], bD: [ang(R.braco_direito, f2), -cot],
}), cor, k);
(R.objetos || []).forEach((o, i) => objetoReceita(c, o, i, b, x, y, s, k, cor, w));
},
ler(c, x, y, s, k, cor) {
const b = boneco(c, x, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], cabeca: -0.08, olhoY: 0.8,
@@ -1268,7 +1387,7 @@
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
atividade: (nome, segundos) => (nome ? iniciarAtividade(nome, segundos) : (pararAtividade(), false)),
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
olharAtividade, palco: desenharPalco,
atividades: Object.fromEntries(Object.entries(ATIVIDADES).map(([k, v]) => [k, { nome: v.nome, emoji: v.emoji }])) };
+152
View File
@@ -0,0 +1,152 @@
"""Estúdio de criação: receitas validadas, prompt com exemplos/base, IA local (servidor falso) e tocar."""
import json
import sys
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import criacao as mod # noqa: E402
from criacao import ATIVIDADES_BASE, EXPRESSOES_BASE, Criacao, montar_prompt, validar_atividade, validar_expressao # noqa: E402
RESPOSTAS = {} # o servidor falso devolve o que o teste colocar aqui
PEDIDOS = []
class IaFalsa(BaseHTTPRequestHandler):
def do_POST(self):
corpo = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
PEDIDOS.append(dict(corpo=corpo, auth=self.headers.get('Authorization')))
tipo = 'expressao' if 'passos' in corpo['response_format']['json_schema']['schema']['properties'] else 'atividade'
texto = '<think>pensando...</think>\n```json\n' + json.dumps(RESPOSTAS[tipo]) + '\n```'
dados = json.dumps(dict(choices=[dict(message=dict(content=texto))])).encode()
self.send_response(200); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(dados)))
self.end_headers(); self.wfile.write(dados)
def do_GET(self):
dados = json.dumps(dict(data=[dict(id='qwen-teste')])).encode()
self.send_response(200); self.send_header('Content-Length', str(len(dados))); self.end_headers(); self.wfile.write(dados)
def log_message(self, *a):
pass
class CriacaoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.c = Criacao(Path(self.tmp.name) / 'c.db', Path(self.tmp.name) / 'c.json')
self.ordens = []
self.c.enfileirar = self.ordens.append
def tearDown(self):
self.tmp.cleanup()
def esperar(self):
for _ in range(100):
if not self.c.ocupado:
return
time.sleep(0.05)
self.fail('criação não terminou')
def test_valida_e_limita_expressao(self):
r = validar_expressao(dict(nome='x' * 80, emoji='<script>', maos='voar', espiral='sim', passos=[
dict(forma='feliz', boca='sorriso', cor='roxo', dx=99, dy=-99, esc=5, ab=-1, duracao=50),
dict(forma='inventada', boca='sorriso', cor='rosa', dx=1, dy=1, esc=1, ab=1, duracao=5)]))
self.assertEqual(len(r['nome']), 30)
self.assertEqual(r['emoji'], '✨')
self.assertEqual((r['maos'], r['espiral']), ('nenhuma', False))
p = r['passos'][0]
self.assertEqual((p['cor'], p['dx'], p['dy'], p['esc'], p['ab']), ('escolhida', 24, -12, 1.3, 0))
self.assertEqual(r['passos'][1]['forma'], 'aberto')
self.assertLessEqual(sum(x['duracao'] for x in r['passos'][:2]), 6.01) # encolheu para 6 s
self.assertEqual(r['passos'][-1]['forma'], 'aberto') # volta ao normal
with self.assertRaises(ValueError):
validar_expressao(dict(passos=[]))
for base in EXPRESSOES_BASE.values(): # os exemplos são válidos
self.assertEqual(len(validar_expressao(base)['passos']), len(base['passos']))
def test_valida_atividade(self):
r = validar_atividade(dict(nome='Malabarismo', emoji='🤹', postura='voando', ritmo=99, braco_esquerdo=dict(de=-500, ate=900),
objetos=[dict(tipo='bola', onde='maos', movimento='pular', quantidade=9), dict(tipo='foguete')] * 3))
self.assertEqual(r['postura'], 'em_pe')
self.assertEqual(r['ritmo'], 3)
self.assertEqual(r['braco_esquerdo'], dict(de=-30, ate=180))
self.assertEqual(len(r['objetos']), 3) # o foguete não existe: sai
self.assertEqual(r['objetos'][0]['quantidade'], 5)
for base in ATIVIDADES_BASE.values():
validar_atividade(base)
def test_prompt_reaproveita_o_que_existe(self):
p = montar_prompt('expressao', 'desconfiado', EXPRESSOES_BASE['timido'])
self.assertIn('Exemplos reais que já existem', p)
self.assertIn('Comemorar', p) # exemplo real
self.assertIn('Parta desta receita', p)
self.assertIn('Tímido', p) # a base escolhida
self.assertIn('"desconfiado"', p)
a = montar_prompt('atividade', 'malabarismo')
self.assertIn('halter', a)
self.assertNotIn('Parta desta receita', a)
def test_ia_local_cria_e_toca(self):
srv = ThreadingHTTPServer(('127.0.0.1', 0), IaFalsa)
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
self.c.configurar(dict(local=dict(url='http://127.0.0.1:%d' % srv.server_port, modelo='qwen-teste', chave='segredo')))
self.assertTrue(self.c.provedores()['local']['pronto'])
self.assertIn('qwen-teste', self.c.testar('local')['mensagem'])
RESPOSTAS['expressao'] = dict(nome='Desconfiado', emoji='🤨', maos='nenhuma', espiral=False, passos=[
dict(forma='bravo', boca='neutra', cor='escolhida', dx=-12, dy=2, esc=0.95, ab=0.6, duracao=0.4),
dict(forma='bravo', boca='neutra', cor='escolhida', dx=12, dy=2, esc=0.95, ab=0.6, duracao=0.8)])
self.c.criar('expressao', 'desconfiado', 'local', base='timido')
with self.assertRaises(ValueError):
self.c.criar('expressao', 'outro', 'local') # um de cada vez
self.esperar()
self.assertEqual(self.c.erro, '')
self.assertEqual(PEDIDOS[-1]['auth'], 'Bearer segredo')
self.assertIn('Tímido', PEDIDOS[-1]['corpo']['messages'][1]['content']) # a base foi junto
item = self.c.lista()[0]
self.assertEqual((item['tipo'], item['nome'], item['ia']), ('expressao', 'Desconfiado', 'local'))
self.assertTrue(self.ordens[-1].startswith('expressao_criada {')) # tocou na hora
RESPOSTAS['atividade'] = dict(ATIVIDADES_BASE['malhar'], nome='Malabarismo', emoji='🤹')
self.c.criar('atividade', 'malabarismo', 'local', base='criado:%d' % item['id']) # base de outro tipo: ignora
self.esperar()
self.assertTrue(self.ordens[-1].startswith('atividade_criada {'))
self.assertEqual(json.loads(self.ordens[-1][17:])['nome'], 'Malabarismo')
self.c.apagar(item['id'])
self.assertEqual([i['nome'] for i in self.c.lista()], ['Malabarismo'])
finally:
srv.shutdown()
def test_figurinha_vai_para_a_biblioteca_de_reacoes(self):
class TreinoFalso:
lock = threading.RLock()
expressao = None
self.c.treinamento = TreinoFalso()
desenho = dict(nome='Sol', local='olhos', formas=[dict(caminho='M 10 10 L 90 10 L 50 90 Z', cor='#ffcc00')])
mod.os.environ['GEMINI_API_KEY'] = 'teste'
original = mod.gemini.gerar_json
mod.gemini.gerar_json = lambda payload: dict(desenho, emoji='☀️')
try:
self.c.criar('figurinha', 'um sol', 'gemini')
self.esperar()
finally:
mod.gemini.gerar_json = original
del mod.os.environ['GEMINI_API_KEY']
self.assertEqual(self.c.erro, '')
self.assertEqual(self.c.lista()[0]['emoji'], '☀️')
self.assertEqual(self.c.treinamento.expressao['receita']['figurinha']['nome'], 'Sol')
def test_erros_claros(self):
with self.assertRaises(ValueError):
self.c.criar('expressao', 'x', 'local') # sem endereço
with self.assertRaises(ValueError):
self.c.configurar(dict(local=dict(url='ftp://nada')))
with self.assertRaises(ValueError):
self.c.criar('robo', 'x', 'gemini')
if __name__ == '__main__':
unittest.main()
+5 -3
View File
@@ -7,7 +7,7 @@
import Paleta from './lib/componentes/Paleta.svelte';
import Selo from './lib/componentes/Selo.svelte';
import Token from './lib/componentes/Token.svelte';
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
import { favoritos, pararTudo, tocarFavorito } from './lib/comandos.svelte';
import { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.svelte';
import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
@@ -19,6 +19,7 @@
import Rosto from './telas/Rosto.svelte';
import Eventos from './telas/Eventos.svelte';
import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia';
@@ -31,6 +32,7 @@
ajustes: Ajustes,
rosto: Rosto,
guia: Guia,
criar: Criar,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
@@ -93,7 +95,7 @@
}
if (/^[1-9]$/.test(e.key)) {
const g = favoritos.lista[Number(e.key) - 1];
if (g) gesto(g);
if (g) tocarFavorito(g);
}
}
@@ -179,7 +181,7 @@
<Tela />
</main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto' && nav.atual !== 'guia'}
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto' && nav.atual !== 'guia' && nav.atual !== 'criar'}
<MiniRosto />
{/if}
</div>
+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 ?? '';
+2 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {
Brain, Camera, Download, Eye, Footprints, Gamepad2, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
Brain, Camera, Download, Eye, Footprints, Gamepad2, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video, Wand2,
} from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api';
@@ -184,6 +184,7 @@ const pirAtivo = $derived(
{/if}
</div>
</Linha>
<Linha icone={Wand2} cor="#af52de" rotulo="Criar com IA" detalhe="Expressões, atividades e figurinhas novas" href="#/criar" />
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo>
+377
View File
@@ -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>
+3
View File
@@ -171,6 +171,9 @@
{/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">