From 05e82dfd75227283ff1ae5e52025b21baeb2fe3c Mon Sep 17 00:00:00 2001 From: julio Date: Tue, 6 Oct 2026 22:22:52 -0300 Subject: [PATCH] =?UTF-8?q?feat(criacao):=20est=C3=BAdio=20para=20criar=20?= =?UTF-8?q?express=C3=B5es,=20atividades=20e=20figurinhas=20com=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/app/assets/carinha.html | 28 +- android/app/assets/rosto-motor.js | 129 ++++++- carinha.html | 28 +- criacao.py | 517 ++++++++++++++++++++++++++ deploy.ps1 | 2 +- kiosk-server.py | 12 +- rosto-motor.js | 129 ++++++- tests/test_criacao.py | 152 ++++++++ web/src/App.svelte | 8 +- web/src/lib/comandos.svelte.ts | 3 + web/src/lib/componentes/Gestos.svelte | 28 +- web/src/lib/criacao.svelte.ts | 100 +++++ web/src/lib/estado.svelte.ts | 10 +- web/src/lib/guia.ts | 21 +- web/src/lib/paleta.svelte.ts | 1 + web/src/lib/rotas.svelte.ts | 3 +- web/src/telas/AoVivo.svelte | 3 +- web/src/telas/Criar.svelte | 377 +++++++++++++++++++ web/src/telas/Reacoes.svelte | 3 + 19 files changed, 1522 insertions(+), 32 deletions(-) create mode 100644 criacao.py create mode 100644 tests/test_criacao.py create mode 100644 web/src/lib/criacao.svelte.ts create mode 100644 web/src/telas/Criar.svelte diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index 8853a5b..0ab791e 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -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); diff --git a/android/app/assets/rosto-motor.js b/android/app/assets/rosto-motor.js index d80c85b..887447a 100644 --- a/android/app/assets/rosto-motor.js +++ b/android/app/assets/rosto-motor.js @@ -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 }])) }; diff --git a/carinha.html b/carinha.html index 8853a5b..0ab791e 100644 --- a/carinha.html +++ b/carinha.html @@ -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); diff --git a/criacao.py b/criacao.py new file mode 100644 index 0000000..cca94e6 --- /dev/null +++ b/criacao.py @@ -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'.*?', '', 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 diff --git a/deploy.ps1 b/deploy.ps1 index 8c24a6c..6e59e98 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -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' ) diff --git a/kiosk-server.py b/kiosk-server.py index 5e9028e..6e26921 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -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) diff --git a/rosto-motor.js b/rosto-motor.js index d80c85b..887447a 100644 --- a/rosto-motor.js +++ b/rosto-motor.js @@ -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 }])) }; diff --git a/tests/test_criacao.py b/tests/test_criacao.py new file mode 100644 index 0000000..72fe2a0 --- /dev/null +++ b/tests/test_criacao.py @@ -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 = 'pensando...\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='
{#each lista as g, i (g)} - {@const info = GESTOS_INFO[g]} + {@const info = infoDe(g)!}
- + {/each} +
+
+ +
+ Qual IA +
+ {#each IAS as x (x.id)} + {@const p = st?.provedores[x.id]} + + {/each} +
+
+ + + + {#if bases.length} + + {/if} + +
+ + +
+ {#if ocupado} + + {st?.provedores[ocupado.ia]?.nome} criando {nomeTipo(ocupado.tipo).toLowerCase()}… {segundos}s + {ocupado.ia !== 'gemini' ? '(IA local pode levar alguns minutos)' : ''} + + {:else if st?.erro} +

{st.erro}

+ {/if} + + + {#if configAberta} +
+

+ Servidor compatível com OpenAI na sua rede (LM Studio com "Serve on Local Network" ou llama-server), por exemplo o ROG Ally. +

+ + + +
+ + +
+
+ {/if} + + +
+ + + + {#snippet acoes()} +
+ + {#each TIPOS as t (t.id)} + + {/each} +
+ {/snippet} + {#if !st} +

Carregando…

+ {:else if !lista.length} +

Nada criado ainda. Escreva um pedido acima.

+ {:else} +
+ {#each lista as c (c.id)} + {@const fav = favoritos.lista.includes(`criado:${c.id}`)} +
+ + {#if podeFavoritar(c.tipo)} + + {/if} + +
+ {/each} +
+ {/if} +
+ + + diff --git a/web/src/telas/Reacoes.svelte b/web/src/telas/Reacoes.svelte index a399ebb..cd6c1e0 100644 --- a/web/src/telas/Reacoes.svelte +++ b/web/src/telas/Reacoes.svelte @@ -171,6 +171,9 @@ {/if} {:else if aba === 'criar'}