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:
+5
-3
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
@@ -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
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user