Compare commits

...
Author SHA1 Message Date
julioandClaude Opus 5.5 947c0a3127 feat(web): migra Conversa e Reacoes para o painel novo
- Conversa: chat empurrado por SSE, indicador de "pensando", gesto da
  resposta com repetir, voz com aviso claro quando o contexto nao e seguro
  e leitura em voz alta so de respostas novas
- Memoria unificada: lembrancas da conversa + rotina/preferencias em abas;
  apagar tudo com confirmacao inline (sem confirm nativo)
- Reacoes: biblioteca com miniaturas estaticas (sem iframe por cartao),
  busca, mostrar no celular, feedback, editar em <dialog>, duplicar,
  ativar e excluir com Desfazer no toast; criar com IA; diario; grade de
  gestos com estrela para favoritos; uma unica previa lateral
- treinamento recarrega por evento SSE ao terminar, sem polling
- corrige icones encolhidos em botoes .pequeno.icone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:51:45 -03:00
julioandClaude Opus 5.5 c4b53a8976 feat(web): cria painel Svelte com shell, Ao vivo e Ajustes
- web/ com Vite + Svelte 5 + TypeScript; servidor entrega web/dist em "/"
  (telas antigas seguem em /dashboard, /painel, /braco...)
- design system em tokens CSS, tema escuro e claro, fontes locais
- estado unico: foto de /estado + um EventSource em /eventos, sem polling
- shell com navegacao lateral/inferior, selos de status reais e E-Stop fixo
- command palette (Ctrl+K), gestos favoritos com atalhos 1-9, g+letra navega
- Ao vivo: camera WebRTC e rastreamento portados para TS, rosto, aparencia,
  sensores do celular, IA local e feed de acontecimentos
- Ajustes: campos do celular agrupados com busca, salvamento otimista e
  restaurar padrao (GET /app/padrao); motor da conversa, Gemini e token
- carinha.html ganha modo ?painel=1 dirigido por postMessage (sem polling)
- teste Playwright de ponta a ponta do painel novo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:47:18 -03:00
julioandClaude Opus 5.5 35d443a358 feat(servidor): adiciona SSE, token, E-Stop real e trava de hardware
- comum.py: ler_json (Origin/JSON/tamanho), gravar_atomico e token
  (CARINHA_TOKEN ou seguranca.json), substituindo 6 copias nos modulos
- eventos.py: hub SSE em /eventos com heartbeat e Last-Event-ID;
  modulos publicam expressao, telemetria, conversa, reacao, ia, motores
- /estado agrega o snapshot inicial; /comando unifica gesto/rosto/cor/reacao
- /parar-tudo desliga o PCA9685, para reacoes e trava motores ate /rearmar
- braco: lease (ttl_ms) e interpolacao com limite de velocidade no servidor
- braco e diagnostico nao disputam o PCA9685 (409 para o segundo dono)
- fila limitada (20) com validade de 10 s; POST /face usa prefixo "rosto"
- /app/config passa a checar Origin e JSON; versao vem de version.json
- figurinhas automaticas do Gemini limitadas a 6 por hora
- rosto entende a ordem "calma" enviada pelo E-Stop

Token so e exigido quando configurado (docs/SEGURANCA.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:34:35 -03:00
julioandClaude Opus 5.5 6c5bd6f432 docs(gitea): aponta repositorio atual e orienta credenciais via GCM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:25:35 -03:00
57 changed files with 6730 additions and 199 deletions
+1
View File
@@ -22,3 +22,4 @@ api-privada.json
api-privada.tmp
ia-config.json
seguranca.json
+3 -8
View File
@@ -2,6 +2,7 @@
import json,os,re,threading,time
from pathlib import Path
import gemini
from comum import gravar_atomico, ler_json
class ConfigAPI:
def __init__(self,path):
@@ -25,9 +26,7 @@ class ConfigAPI:
key=d.get('chave') or os.environ.get('GEMINI_API_KEY','')
saved=dict(modelo=d['modelo'])
if key:saved['chave']=key
tmp=self.path.with_suffix('.tmp');fd=os.open(str(tmp),os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
with os.fdopen(fd,'w',encoding='utf-8') as f:json.dump(saved,f)
os.chmod(tmp,0o600);tmp.replace(self.path);os.chmod(self.path,0o600)
gravar_atomico(self.path,saved,privado=True)
gemini.MODELO=d['modelo']
if key:os.environ['GEMINI_API_KEY']=key
self.ultimo_teste=None
@@ -47,11 +46,7 @@ class ConfigAPI:
try:
if method=='GET' and route.endswith('/status'):result=self.status()
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
size=int(h.headers.get('Content-Length',0))
if not 0<size<=2048 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 2048 bytes')
d=json.loads(h.rfile.read(size))
d=ler_json(h,2048)
if route.endswith('/salvar'):result=self.salvar(d)
elif route.endswith('/testar'):result=self.testar()
else:raise ValueError('Rota inválida')
+66 -17
View File
@@ -3,11 +3,25 @@ import copy
import json
import math
from pathlib import Path
import threading
import time
from comum import Conflito, gravar_atomico, ler_json
from servos import integer
NAMES=['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base']
CHANNELS=[0,1,2,3,4,5,7]
LEASE_PADRAO=2000 # ms; o painel novo manda prazos curtos e renova enquanto o botão está pressionado
PASSO=.02 # 50 Hz de interpolação no servidor
def velocidade_valida(v):
if v is None: return None
if isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) or not 5<=v<=360:
raise ValueError('Velocidade deve ficar entre 5 e 360 graus/s')
return float(v)
def prazo_valido(ms):
if ms is None: return LEASE_PADRAO
return integer(ms,100,2000)
class Braco:
def __init__(self,path,servos):
@@ -15,6 +29,8 @@ class Braco:
self.config=dict(calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[])
try: self.config=self.validate(json.loads(self.path.read_text(encoding='utf-8')))
except (OSError,ValueError,TypeError): pass
self.atual=None; self.alvo=None; self.velocidade=None; self.lease=0
threading.Thread(target=self.loop,daemon=True).start()
def validate_pose(self,pose,calibration):
if not isinstance(pose,list) or len(pose)!=7: raise ValueError('Esperava sete ângulos')
pulses={}
@@ -53,27 +69,64 @@ class Braco:
with self.servos.lock:
result=copy.deepcopy(self.config)
result.update(disponivel=self.servos.hw is not None,erro=self.servos.error,
dono=self.servos.dono,travado=self.servos.travado,atual=self.atual,
movendo=self.alvo is not None,
articulacoes=[dict(nome=name,canal=ch) for name,ch in zip(NAMES,CHANNELS)])
return result
def save(self,data):
clean=self.validate(data)
with self.servos.lock:
if self.servos.active: raise ValueError('Pare os motores antes de salvar')
tmp=self.path.with_suffix('.tmp'); tmp.write_text(json.dumps(clean,ensure_ascii=False,indent=2),encoding='utf-8'); tmp.replace(self.path)
gravar_atomico(self.path,clean)
self.config=clean
return self.status()
def move(self,pose):
with self.servos.lock:
pulses=self.validate_pose(pose,self.config['calibracao'])
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
def escrever(self,pulses,prazo):
try:
for channel,pulse in pulses.items():
self.servos.hw.pulse(channel,pulse)
self.servos.active[channel]=time.monotonic()+2
self.servos.active[channel]=prazo
except OSError:
self.servos.stop()
self.alvo=None; self.servos.stop()
raise
return dict(ok=True,pulsos=pulses)
def move(self,pose,velocidade=None,ttl_ms=None):
"""Sem velocidade: vai direto (compatível). Com velocidade: o servidor interpola até o alvo.
Em ambos os casos os motores desligam sozinhos quando o prazo (ttl_ms) vence sem renovação."""
velocidade=velocidade_valida(velocidade); ttl=prazo_valido(ttl_ms)
with self.servos.lock:
pulses=self.validate_pose(pose,self.config['calibracao'])
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
self.servos.reservar('braco')
prazo=time.monotonic()+ttl/1000
if velocidade is None or self.atual is None:
self.alvo=None
self.escrever(pulses,prazo)
self.atual=[float(a) for a in pose]
return dict(ok=True,pulsos=pulses,movendo=False)
self.alvo=[float(a) for a in pose]; self.velocidade=velocidade; self.lease=prazo
for channel in list(self.servos.active): self.servos.active[channel]=prazo
return dict(ok=True,pulsos=pulses,movendo=True)
def cancelar(self):
with self.servos.lock: self.alvo=None
def passo(self,agora,dt):
with self.servos.lock:
if self.alvo is None: return
if agora>=self.lease or self.servos.travado or (self.servos.dono!='braco' and self.servos.active):
self.alvo=None; return
delta=self.velocidade*dt
nova=[a+max(-delta,min(delta,t-a)) for a,t in zip(self.atual,self.alvo)]
try: pulses=self.validate_pose(nova,self.config['calibracao'])
except ValueError:
self.alvo=None; return
self.escrever(pulses,self.lease)
self.atual=nova
if nova==self.alvo: self.alvo=None
def loop(self):
anterior=time.monotonic()
while not self.servos.closed.wait(PASSO):
agora=time.monotonic()
try: self.passo(agora,agora-anterior)
except OSError as e: self.servos.error=str(e)
anterior=agora
@staticmethod
def angle_pulse(angle,center):
return round(500+angle*(center-500)/90 if angle<=90 else center+(angle-90)*(2500-center)/90)
@@ -86,6 +139,7 @@ class Braco:
outputs[other]=integer(center_sum-pulse,500,2500)
with self.servos.lock:
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
self.servos.reservar('braco'); self.alvo=None; self.atual=None
for ch,us in outputs.items():
self.servos.hw.pulse(ch,us)
self.servos.active[ch]=time.monotonic()+2
@@ -96,21 +150,16 @@ class Braco:
try:
if method=='GET' and route=='/braco/status': result=self.status()
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')): raise ValueError('Origem inválida')
if h.headers.get('Content-Type','').split(';')[0]!='application/json': raise ValueError('Use JSON')
size=int(h.headers.get('Content-Length','0'))
if not 0<size<=65536: raise ValueError('Tamanho inválido')
data=json.loads(h.rfile.read(size))
if not isinstance(data,dict): raise ValueError('Objeto obrigatório')
data=ler_json(h,65536)
if route=='/braco/config': result=self.save(data)
elif route=='/braco/mover': result=self.move(data.get('angulos'))
elif route=='/braco/mover': result=self.move(data.get('angulos'),data.get('velocidade'),data.get('ttl_ms'))
elif route=='/braco/calibrar': result=self.calibrate(data.get('canal'),data.get('pulso'))
elif route=='/braco/parar': result=self.servos.stop()
elif route=='/braco/parar': self.cancelar(); result=self.servos.stop()
else: raise ValueError('Rota inválida')
else:
h.enviar(json.dumps(dict(erro='Método inválido')),codigo=405); return True
h.enviar(json.dumps(result,ensure_ascii=False))
except Conflito as e: h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=409)
except (ValueError,TypeError) as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=400)
except OSError as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=503)
return True
+15 -1
View File
@@ -424,6 +424,9 @@ function trata(ordem) {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
@@ -484,7 +487,18 @@ async function aplicarAparencia() {
} catch (e) { /* sem servidor — segue com o padrao */ }
}
setTimeout(aplicarAparencia, 200);
if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
if (MODO_PAINEL) {
window.carinhaAtiva = false;
addEventListener('message', (e) => {
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
+7 -9
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import threading
import time
from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
EVENTOS=('som','movimento','saudacao','sozinho')
@@ -20,15 +21,17 @@ class Cerebro:
self.executor='S10' if '192.168.1.16' in self.endpoint else 'Orange Pi'
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
self.publicar=lambda tipo,dados:None
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start()
def config(self,ativo):
if type(ativo) is not bool:raise ValueError('ativo deve ser booleano')
with self.lock:
tmp=self.path.with_suffix('.tmp');tmp.write_text(json.dumps(dict(ativo=ativo)));tmp.replace(self.path)
gravar_atomico(self.path,dict(ativo=ativo))
self.ativo=ativo;self.generation+=1;self.pending=None
return self.status()
status=self.status();self.publicar('ia',status)
return status
def status(self):
with self.lock:return dict(ativo=self.ativo,ocupado=self.busy,modelo='Qwen3-0.6B Q4_K_M',executor=self.executor,ultima=self.result,erro=self.error)
def event(self,event):
@@ -77,6 +80,7 @@ class Cerebro:
finally:
self.last=time.monotonic()
with self.lock:self.busy=False
self.publicar('ia',self.status())
def rotas(self,h,method):
route=h.path.split('?',1)[0]
if route=='/ia/modelo' and method=='GET':
@@ -95,13 +99,7 @@ class Cerebro:
try:
if method=='GET' and route=='/ia/status':result=self.status()
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
if h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON')
size=int(h.headers.get('Content-Length','0'))
if not 0<size<=2048:raise ValueError('Tamanho inválido')
data=json.loads(h.rfile.read(size))
if not isinstance(data,dict):raise ValueError('Objeto obrigatório')
data=ler_json(h,2048)
if route=='/ia/config':result=self.config(data.get('ativo'))
elif route=='/ia/evento':result=self.event(data.get('evento'))
else:raise ValueError('Rota inválida')
+76
View File
@@ -0,0 +1,76 @@
"""Peças compartilhadas pelos módulos HTTP: leitura de JSON, gravação atômica e token."""
import hmac
import json
import os
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
class Conflito(Exception):
"""Pedido válido, mas o estado atual não permite (vira HTTP 409)."""
def origem_valida(h):
origin = h.headers.get('Origin')
host = h.headers.get('Host', '')
return not origin or origin in ('http://' + host, 'https://' + host)
def ler_json(h, limite):
"""Corpo JSON de um POST do painel; ValueError com mensagem legível se inválido."""
if not origem_valida(h):
raise ValueError('Origem inválida')
if h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
raise ValueError('Use JSON')
tamanho = int(h.headers.get('Content-Length', '0'))
if not 0 < tamanho <= limite:
raise ValueError(f'Corpo vazio ou maior que {limite} bytes')
dados = json.loads(h.rfile.read(tamanho))
if not isinstance(dados, dict):
raise ValueError('Objeto JSON obrigatório')
return dados
def gravar_atomico(caminho, obj, privado=False):
caminho = Path(caminho)
tmp = caminho.with_suffix('.tmp')
texto = json.dumps(obj, ensure_ascii=False, indent=2)
if privado:
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(texto)
else:
tmp.write_text(texto, encoding='utf-8')
tmp.replace(caminho)
if privado:
os.chmod(caminho, 0o600)
def carregar_token(pasta):
"""CARINHA_TOKEN do ambiente ou {"token": ...} em seguranca.json; None = sem autenticação."""
token = os.environ.get('CARINHA_TOKEN', '').strip()
if not token:
try:
token = str(json.loads((Path(pasta) / 'seguranca.json').read_text(encoding='utf-8')).get('token', '')).strip()
except (OSError, ValueError, AttributeError):
token = ''
if token and len(token) < 8:
raise ValueError('CARINHA_TOKEN precisa ter pelo menos 8 caracteres')
return token or None
def token_do_pedido(h):
auth = h.headers.get('Authorization', '')
if auth.lower().startswith('bearer '):
return auth[7:].strip()
if h.headers.get('X-Carinha-Token'):
return h.headers['X-Carinha-Token'].strip()
# EventSource e o app Android não mandam cabeçalhos próprios com facilidade.
return parse_qs(urlsplit(h.path).query).get('t', [''])[0]
def autorizado(h):
esperado = getattr(h.server, 'token', None)
if not esperado:
return True
return hmac.compare_digest(token_do_pedido(h).encode(), esperado.encode())
+34 -13
View File
@@ -7,8 +7,16 @@ from pathlib import Path
import subprocess
import threading
import time
from comum import origem_valida
from sinalizacao import Sinalizacao
def versao():
try:
return json.loads((Path(__file__).with_name('version.json')).read_text(encoding='utf-8'))['versao']
except (OSError, ValueError, KeyError):
return 'desconhecida'
PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
ouvir=True, reagir=True, somlim=0.35, somganho=0.4,
somaltolim=0.55, sompausa=20, somcooldown=5,
@@ -61,6 +69,7 @@ class ControlePC:
self.frame_at = 0
self.frame_seq = 0
self.rtc = Sinalizacao()
self.publicar = lambda tipo, dados: None
try:
self.config.update(validar(json.loads(self.arquivo.read_text(encoding='utf-8'))))
except (OSError, ValueError):
@@ -80,11 +89,22 @@ class ControlePC:
self.frame_at = 0
return novo.copy()
def resumo(self, host=''):
"""Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria')."""
with self.lock:
agora = time.monotonic()
idade = agora - self.status_at
return dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None,
celular_ip=self.celular_ip, servidor_ip=host, recarga=self.recarga.copy(),
estado=self.estado.copy(), preview=self.config['preview'],
camera_recente=bool(self.frame) and agora - self.frame_at < 3 and self.config['preview'],
frame_seq=self.frame_seq)
def rotas(self, h, metodo):
if self.rtc.atender(h, metodo, self.config['preview']):
return True
rota = h.path.split('?', 1)[0].rstrip('/')
if rota not in ('/app/config', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
if rota not in ('/app/config', '/app/padrao', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
return False
def enviar(dados, codigo=200):
@@ -92,6 +112,10 @@ class ControlePC:
try:
if metodo == 'POST':
if not origem_valida(h):
raise ValueError('Origem inválida')
if rota == '/app/config' and h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
raise ValueError('Use JSON')
tamanho = int(h.headers.get('Content-Length', '0'))
limite = 1_000_000 if rota == '/camera/frame' else 16384
if tamanho <= 0 or tamanho > limite:
@@ -104,11 +128,15 @@ class ControlePC:
else:
dados = None
if rota == '/app/health':
enviar({'projeto': 'carinha-pc', 'versao': '0.7.0'})
if rota == '/app/padrao':
enviar(PADRAO)
elif rota == '/app/health':
enviar({'projeto': 'carinha-pc', 'versao': versao()})
elif rota == '/app/config':
if metodo == 'POST':
enviar({'ok': True, 'config': self.atualizar(dados)})
config = self.atualizar(dados)
self.publicar('app_config', config)
enviar({'ok': True, 'config': config})
else:
with self.lock:
config = self.config.copy()
@@ -123,17 +151,10 @@ class ControlePC:
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
self.recarga["confirmada"] = True
self.status_at = time.monotonic()
self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0]))
enviar({'ok': True})
else:
with self.lock:
idade = time.monotonic() - self.status_at
frame_idade = time.monotonic() - self.frame_at
resultado = dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None,
celular_ip=self.celular_ip, servidor_ip=h.headers.get("Host", "").split(":")[0], recarga=self.recarga.copy(),
estado=self.estado.copy(), preview=self.config['preview'],
camera_recente=bool(self.frame) and frame_idade < 3 and self.config['preview'],
frame_seq=self.frame_seq)
enviar(resultado)
enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
elif rota == '/camera/frame':
with self.lock:
if metodo == 'POST':
+20 -9
View File
@@ -6,14 +6,18 @@ import threading
import time
import unicodedata
import re
from collections import deque
from datetime import datetime, timezone, timedelta
from contextlib import contextmanager
from urllib.request import Request, urlopen
from cerebro import GESTOS
from pathlib import Path
from gemini import responder_gemini
from comum import gravar_atomico, ler_json
import gemini
FIGURINHAS_POR_HORA = 6 # cada figurinha automática é mais uma chamada paga ao Gemini
def resposta_factual(texto,agora=None):
normal=''.join(c for c in unicodedata.normalize('NFD',texto.lower()) if not unicodedata.combining(c))
normal=' '.join(normal.strip(' ?!.').split())
@@ -93,6 +97,14 @@ class Conversa:
if saved.get('provedor') in ('local','gemini'):self.provedor=saved['provedor']
except (OSError,ValueError,AttributeError):pass
self.lock=threading.Lock();self.busy=False;self.ultima=None;self.erro='';self.generation=0
self.publicar=lambda tipo,dados:None;self.figurinhas_auto=deque()
def avisar(self):
self.publicar('conversa',self.status())
def pode_gerar_figurinha(self,agora=None):
agora=agora or time.time()
while self.figurinhas_auto and agora-self.figurinhas_auto[0]>3600:self.figurinhas_auto.popleft()
if len(self.figurinhas_auto)>=FIGURINHAS_POR_HORA:return False
self.figurinhas_auto.append(agora);return True
def status(self):
with self.lock:return dict(provedor=self.provedor,modelo=gemini.MODELO if self.provedor=='gemini' else 'Qwen3-0.6B',gemini_configurado=bool(os.environ.get('GEMINI_API_KEY')),ocupado=self.busy,ultima=self.ultima,erro=self.erro,historico=self.memoria.historico(),lembrancas=self.memoria.listar())
def enviar(self,texto):
@@ -108,7 +120,9 @@ class Conversa:
self.memoria.registrar('assistant',factual['texto'],2 if self.provedor=='gemini' else 1)
self.ultima=dict(factual,id=time.time_ns(),horario=time.time(),segundos=0,fonte='relogio')
self.busy=False;self.cerebro.conversando=False
return dict(ok=True)
factual=True
self.avisar()
if factual is True:return dict(ok=True)
threading.Thread(target=self.responder,args=(generation,),daemon=True).start()
return dict(ok=True)
def responder(self,generation):
@@ -140,32 +154,28 @@ class Conversa:
self.ultima=dict(result,id=time.time_ns(),horario=time.time(),segundos=round(time.monotonic()-inicio,2))
if self.provedor=='gemini' and treino:
if type(reaction) is int and reaction in [r['id'] for r in catalog]:treino.testar(reaction)
elif not treino.status()['ocupado']:
elif not treino.status()['ocupado'] and self.pode_gerar_figurinha():
treino.gerar_figurinha(history[-1]['content'])
except Exception as e:
with self.lock:self.erro=str(e)[:200] if generation==self.generation else ''
finally:
with self.lock,self.cerebro.lock:
self.busy=False;self.cerebro.conversando=False;self.cerebro.last=time.monotonic()
self.avisar()
def rotas(self,h,method):
route=h.path.split('?',1)[0]
if not route.startswith('/conversa/'):return False
try:
if method=='GET' and route=='/conversa/status':result=self.status()
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
size=int(h.headers.get('Content-Length','0'))
if not 0<size<=4096 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 4096 bytes')
data=json.loads(h.rfile.read(size))
if not isinstance(data,dict):raise ValueError('Objeto obrigatório')
data=ler_json(h,4096)
if route=='/conversa/config':
with self.lock:
if self.busy:raise ValueError('Aguarde a resposta antes de trocar a IA')
provider=data.get('provedor')
if provider not in ('local','gemini'):raise ValueError('Provedor inválido')
if provider=='gemini' and not os.environ.get('GEMINI_API_KEY'):raise ValueError('Chave Gemini não configurada')
tmp=self.config_path.with_suffix('.tmp');tmp.write_text(json.dumps(dict(provedor=provider)));tmp.replace(self.config_path)
gravar_atomico(self.config_path,dict(provedor=provider))
self.provedor=provider
result=self.status()
elif route=='/conversa/enviar':result=self.enviar(data.get('texto'))
@@ -183,6 +193,7 @@ class Conversa:
else:self.memoria.apagar(data.get('id'))
result=dict(ok=True)
else:raise ValueError('Rota inválida')
if route!='/conversa/enviar':self.avisar()
else:raise ValueError('Método inválido')
h.enviar(json.dumps(result,ensure_ascii=False))
except (ValueError,TypeError) as e:h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=400)
+17 -5
View File
@@ -1,9 +1,21 @@
# Repositório do Carinha
Código em https://git.roterizacao.cloud/PSAutomation/painel-ia-gemini, branch `carinha-orange-pi`, pasta `carinha/`.
O repositório já contém outro sistema; sua branch principal foi preservada.
Código em https://git.roterizacao.cloud/julio/carinha-control-panel, branch `main`.
(Antes vivia em `PSAutomation/painel-ia-gemini`, branch `carinha-orange-pi`, pasta `carinha/`; aquele repositório não é mais a fonte.)
Credenciais, chave de assinatura Android, bases de conversa, logs, configurações do aparelho e arquivos gerados não são publicados.
## Credenciais do Git
Nunca coloque usuário/senha na URL do remote (`https://usuario:senha@...`): ela fica em texto claro em `.git/config`.
Use a URL limpa e deixe o Git Credential Manager guardar o login:
```
git remote set-url origin https://git.roterizacao.cloud/julio/carinha-control-panel.git
git config --global credential.helper manager
```
No primeiro `git push` o GCM pede o login (ou um token de acesso do Gitea, preferível à senha) e o guarda no cofre do Windows.
## O que não é publicado
Credenciais, chave de assinatura Android, bases de conversa, logs, configurações do aparelho e arquivos gerados.
Modelos MediaPipe podem ser obtidos com `python obter-rastreamento.py`. As bibliotecas nativas llama.cpp ARM64 e a chave de assinatura Android precisam ser fornecidas separadamente para compilar o APK; permanecem na instalação atual. O servidor Python e a interface HTML estão no código versionado. Gemini usa GEMINI_API_KEY no ambiente do serviço.
Esta publicação não altera a instalação ativa no Orange Pi.
+43
View File
@@ -0,0 +1,43 @@
# Segurança do servidor
## Token de controle
Com um token configurado, o servidor (porta 8100) exige `Authorization: Bearer <token>`
em todo POST e nas leituras de dados (`/eventos`, `/estado`, `/fila`, `/app/*`, `/rtc/*`,
`/camera/*`, `/conversa/*`, `/treinamento/*`, `/braco/*`, `/servos/*`, `/ia/status`, `/api/*`).
EventSource e o app Android podem mandar `?t=<token>` na URL.
Continuam públicos apenas: páginas estáticas, `/app/health`, `/faces`, `/gestos`,
`GET /config` (aparência), `/ia/modelo` e `/app.apk`.
Configure **um** dos dois (mínimo 8 caracteres):
```
# /etc/carinha.env, carregado pelo carinha-pc.service (EnvironmentFile=)
CARINHA_TOKEN=troque-por-um-segredo-longo
```
ou `seguranca.json` na pasta do servidor (fora do git):
```json
{"token": "troque-por-um-segredo-longo"}
```
Sem token configurado o servidor fica aberto na rede local, como antes.
> **Ordem de ativação.** O painel antigo e o `carinha.html`/APK atuais ainda não enviam token.
> Ative o token na placa só depois de publicar o painel novo e o rosto/APK com suporte a token
> (fases 2–5 do plano de modernização). Até lá o servidor já aplica as demais proteções:
> checagem de Origin em todos os POST, comandos validados e E-Stop.
## E-Stop
`POST /parar-tudo` desliga as 16 saídas do PCA9685, cancela a interpolação do braço,
para reações e manda `calma` ao rosto. Braço e servos ficam **travados** (HTTP 409) até
`POST /rearmar`.
## Trava de hardware
Braço (`/braco/*`) e diagnóstico (`/servos/*`) usam o mesmo PCA9685 com limites
diferentes. Quem estiver com motores ativos é o dono; o outro recebe 409 até os motores
desligarem (parar, ou ~2 s sem comando).
+70
View File
@@ -0,0 +1,70 @@
"""Hub Server-Sent Events: um único fluxo /eventos substitui os pollings do painel."""
from collections import deque
import json
import threading
import time
from urllib.parse import parse_qs, urlsplit
PING = 15
class Eventos:
def __init__(self, buffer=200):
self.cond = threading.Condition()
self.seq = 0
self.buffer = deque(maxlen=buffer)
self.fechado = False
def publicar(self, tipo, dados):
corpo = json.dumps(dados, ensure_ascii=False)
with self.cond:
self.seq += 1
self.buffer.append((self.seq, tipo, corpo))
self.cond.notify_all()
return self.seq
def desde(self, seq):
with self.cond:
return [e for e in self.buffer if e[0] > seq]
def fechar(self):
with self.cond:
self.fechado = True
self.cond.notify_all()
def atender(self, h):
url = urlsplit(h.path)
try:
ultimo = int(h.headers.get('Last-Event-ID') or parse_qs(url.query).get('desde', ['0'])[0])
except ValueError:
ultimo = 0
with self.cond:
# Cliente novo (ou que perdeu demais): começa do agora e lê /estado.
if ultimo <= 0 or (self.buffer and ultimo < self.buffer[0][0] - 1) or ultimo > self.seq:
ultimo = self.seq
h.send_response(200)
h.send_header('Content-Type', 'text/event-stream; charset=utf-8')
h.send_header('Cache-Control', 'no-store')
h.send_header('X-Accel-Buffering', 'no')
h.end_headers()
try:
h.wfile.write(f'retry: 3000\nevent: pronto\nid: {ultimo}\ndata: {{"seq": {ultimo}}}\n\n'.encode())
while True:
limite = time.monotonic() + PING
with self.cond:
while not self.fechado and (not self.buffer or self.buffer[-1][0] <= ultimo):
resta = limite - time.monotonic()
if resta <= 0:
break
self.cond.wait(resta)
if self.fechado:
return
novos = [e for e in self.buffer if e[0] > ultimo]
if not novos:
h.wfile.write(b': ping\n\n')
continue
for seq, tipo, corpo in novos:
h.wfile.write(f'id: {seq}\nevent: {tipo}\ndata: {corpo}\n\n'.encode())
ultimo = seq
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
return
+290 -110
View File
@@ -1,38 +1,39 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
kiosk-server.py — servidor local do PC: painel, carinha e fila de ordens.
kiosk-server.py — servidor do Carinha (Orange Pi, porta 8100): painel, rosto e controle.
Alem de entregar os arquivos da pasta (padrao /root/carinha), ele expõe:
Estado em tempo real:
GET /eventos -> Server-Sent Events (expressao, telemetria, conversa, reacao,
ia, motores, estop, aparencia, app_config, treinamento)
GET /estado -> foto de tudo para o primeiro carregamento (inclui 'seq')
GET /fila -> devolve e limpa a fila, em JSON: ["feliz","piscar"]
GET /gesto/<nome> -> poe <nome> na fila (ex.: /gesto/coracao)
POST /gesto -> corpo = nome do gesto
Comandos:
POST /comando -> {"tipo": "gesto"|"rosto"|"cor"|"reacao"|"parar_reacao"|"recarregar", "valor": ...}
POST /parar-tudo -> E-Stop: desliga todos os servos, para reações e acalma o rosto
POST /rearmar -> libera os motores depois do E-Stop
GET /fila?aparelho=1 -> ordens pendentes para o celular (até o rosto migrar para /eventos)
A pagina da carinha pergunta /fila a cada meio segundo; assim da para mandar
um gesto do PC com um curl, igual ao antigo FIFO /tmp/eilik.fifo:
Autenticação: se CARINHA_TOKEN (ambiente) ou seguranca.json {"token": ...} existir, toda
rota de controle/dados exige "Authorization: Bearer <token>" (ou ?t=<token> no EventSource
e no app). Sem token configurado o servidor fica aberto como antes.
curl http://orangepi:8099/gesto/feliz
Alem disso guarda a APARENCIA da carinha (rosto + cor dos olhos), para o painel
personalizar e a pagina aplicar sozinha ao carregar:
GET /config -> {"rosto":"carinha","cor":[92,214,255]}
POST /config -> corpo JSON {"rosto":"miudo","cor":[255,120,0]}
E serve o painel de controle em http://<maquina>:8099/painel .
So usa a biblioteca padrao (http.server). Nada de instalacao extra.
Só usa a biblioteca padrão. Nada de instalação extra.
"""
import argparse
from collections import deque
import json
import os
import threading
import time
import uuid
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote
from pathlib import Path
from controle_pc import ControlePC
from comum import autorizado, carregar_token, gravar_atomico, ler_json
from controle_pc import ControlePC, versao
from eventos import Eventos
from servos import ControleServos
from braco import Braco
from cerebro import Cerebro
@@ -41,7 +42,6 @@ from treinamento import Treinamento
from api_config import ConfigAPI
TRAVA = threading.Lock()
FILA = []
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
ROSTOS = ["eilik", "carinha", "miudo"]
@@ -50,12 +50,65 @@ ROSTOS = ["eilik", "carinha", "miudo"]
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
"dormir", "acordar", "aleatorio", "parar"]
# Ordens internas que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"}
# Apar┌┐ncia atual da carinha (personalizacao pelo painel). Fica gravada num
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
# arquivo ao lado do servidor, para sobreviver a reinicios.
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]}
ARQUIVO_APARENCIA = "aparencia.json"
# 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/", "/camera/", "/conversa/", "/treinamento/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
class Fila:
"""Ordens para o celular: limitada e com validade, para não repetir ordens velhas
quando o aparelho volta depois de um tempo offline."""
def __init__(self, maximo=20, validade=10.0):
self.lock = threading.Lock()
self.itens = deque(maxlen=maximo)
self.validade = validade
def adicionar(self, ordem, agora=None):
with self.lock:
if ordem == "recarregar" and any(o == ordem for _, o in self.itens):
return
self.itens.append((agora if agora is not None else time.monotonic(), ordem))
def consumir(self, aparelho=True, agora=None):
"""O celular leva tudo; um rosto aberto no navegador (kiosk) leva tudo menos 'recarregar'."""
agora = agora if agora is not None else time.monotonic()
with self.lock:
validos = [(t, o) for t, o in self.itens if agora - t <= self.validade]
pendentes = [o for _, o in validos if aparelho or o != "recarregar"]
self.itens.clear()
if not aparelho:
self.itens.extend((t, o) for t, o in validos if o == "recarregar")
return pendentes
def ordens(self):
with self.lock:
return [o for _, o in self.itens]
def limpar(self):
with self.lock:
self.itens.clear()
FILA = Fila()
EVENTOS = Eventos()
ESTOP = {"ativo": False, "desde": None}
def enfileirar(ordem):
"""Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis."""
FILA.adicionar(ordem)
EVENTOS.publicar("expressao", {"ordem": ordem})
def carregar_aparencia():
try:
@@ -73,14 +126,20 @@ def carregar_aparencia():
def salvar_aparencia():
try:
with open(ARQUIVO_APARENCIA, "w", encoding="utf-8") as f:
json.dump(APARENCIA, f, ensure_ascii=False, indent=2)
except Exception:
gravar_atomico(ARQUIVO_APARENCIA, APARENCIA)
except OSError:
pass
def validar_cor(valor):
cor = [max(0, min(255, int(v))) for v in list(valor)[:3]]
if len(cor) != 3:
raise ValueError("Cor precisa de três números")
return cor
def aplicar_aparencia(dados):
"""Guarda rosto/cor e poe as ordens na fila para a pagina aplicar na hora."""
"""Guarda rosto/cor e manda as ordens para a página aplicar na hora."""
ordens = []
with TRAVA:
if "rosto" in dados:
@@ -90,24 +149,43 @@ def aplicar_aparencia(dados):
ordens.append("rosto " + nome)
if "cor" in dados:
try:
cor = [max(0, min(255, int(v))) for v in list(dados["cor"])[:3]]
if len(cor) == 3:
APARENCIA["cor"] = cor
ordens.append("cor %d,%d,%d" % tuple(cor))
except Exception:
APARENCIA["cor"] = validar_cor(dados["cor"])
ordens.append("cor %d,%d,%d" % tuple(APARENCIA["cor"]))
except (TypeError, ValueError):
pass
FILA.extend(ordens)
aparencia = dict(APARENCIA)
for ordem in ordens:
enfileirar(ordem)
if ordens:
salvar_aparencia()
EVENTOS.publicar("aparencia", aparencia)
return ordens
def ordem_de_comando(tipo, valor):
"""Traduz {tipo, valor} para a ordem de texto que o rosto entende; ValueError se inválido."""
if tipo == "gesto":
nome = str(valor or "").strip().lower()
if nome not in GESTOS and nome not in ORDENS_INTERNAS:
raise ValueError("Gesto desconhecido: " + nome)
return nome
if tipo == "rosto":
nome = str(valor or "").strip().lower()
if nome not in ROSTOS:
raise ValueError("Rosto desconhecido: " + nome)
return "rosto " + nome
raise ValueError("Tipo de comando inválido")
class Handler(SimpleHTTPRequestHandler):
extensoes_map = {".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
".mp4": "video/mp4"}
def guess_type(self, caminho):
@@ -120,32 +198,71 @@ class Handler(SimpleHTTPRequestHandler):
self.send_header("Content-Type", tipo)
self.send_header("Content-Length", str(len(dados)))
self.send_header("Cache-Control", "no-store")
if codigo == 401:
self.send_header("WWW-Authenticate", 'Bearer realm="carinha"')
self.end_headers()
self.wfile.write(dados)
def json(self, dados, codigo=200):
self.enviar(json.dumps(dados, ensure_ascii=False), codigo=codigo)
def rota(self):
return self.path.split("?", 1)[0]
def acesso_negado(self, metodo):
"""True (e já respondeu 401) se a rota exige token e o pedido não trouxe o certo."""
rota = self.rota()
protegida = metodo == "POST" or (rota not in PUBLICAS_GET and rota.startswith(PREFIXOS_API))
if protegida and not autorizado(self):
self.json({"erro": "Token ausente ou inválido"}, 401)
return True
return False
def modulos(self):
s = self.server
return [getattr(s, nome) for nome in ("api_config", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)]
def estado(self):
s = self.server
host = self.headers.get("Host", "").split(":")[0]
with TRAVA:
aparencia = dict(APARENCIA)
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)),
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS)
if hasattr(s, "controle"):
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
if hasattr(s, "servos"):
dados["motores"] = s.servos.motores()
if hasattr(s, "conversa"):
dados["conversa"] = s.conversa.status()
if hasattr(s, "cerebro"):
dados["ia"] = s.cerebro.status()
if hasattr(s, "treinamento"):
dados["reacao"] = s.treinamento.expressao
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
if hasattr(s, "api_config"):
dados["api"] = s.api_config.status()
return dados
def do_GET(self):
if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "GET"):return
if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'GET'):
if self.acesso_negado("GET"):
return
if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'GET'):
for modulo in self.modulos():
if modulo.rotas(self, "GET"):
return
if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'GET'):
return
if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'GET'):
return
if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'GET'):
return
if self.server.controle.rotas(self, 'GET'):
return
caminho = self.path.split("?", 1)[0]
caminho = self.rota()
partes = [p for p in caminho.split("/") if p]
if caminho == "/eventos":
return EVENTOS.atender(self)
if caminho == "/estado":
return self.json(self.estado())
if partes and partes[0] == "fila":
with TRAVA:
aparelho = 'aparelho=1' in self.path
pendentes = [x for x in FILA if x!='recarregar' or aparelho]
FILA[:] = [x for x in FILA if x=='recarregar' and not aparelho]
return self.enviar(json.dumps(pendentes))
# Painéis não leem a fila: observam o rosto por /eventos.
return self.enviar(json.dumps(FILA.consumir(aparelho="aparelho=1" in self.path)))
if partes and partes[0] == "faces":
return self.enviar(json.dumps({"rostos": ROSTOS}))
@@ -156,12 +273,6 @@ class Handler(SimpleHTTPRequestHandler):
if partes and partes[0] == "gestos":
return self.enviar(json.dumps({"gestos": GESTOS}))
if len(partes) == 2 and partes[0] == "face":
nome = unquote(partes[1]).lower()
with TRAVA:
FILA.append("rosto " + nome)
return self.enviar(json.dumps({"ok": True, "rosto": nome}))
if partes and partes[0] == "diag":
# a propria pagina relata o tamanho que recebeu (ajuda a achar
# barras/enquadramento errado no kiosk). Aparece no journal.
@@ -169,11 +280,27 @@ class Handler(SimpleHTTPRequestHandler):
print("DIAG kiosk:", info, flush=True)
return self.enviar(json.dumps({"ok": True}))
if len(partes) == 2 and partes[0] == "gesto":
nome = unquote(partes[1]).lower() # aceita %20 para espacos
with TRAVA:
FILA.append(nome)
return self.enviar(json.dumps({"ok": True, "gesto": nome}))
# Obsoletos (GET que muda estado); ficam até o painel antigo sair. Use POST /comando.
if len(partes) == 2 and partes[0] in ("gesto", "face"):
tipo = "gesto" if partes[0] == "gesto" else "rosto"
try:
ordem = ordem_de_comando(tipo, unquote(partes[1]))
except ValueError as e:
return self.json({"erro": str(e)}, 400)
enfileirar(ordem)
return self.json({"ok": True, partes[0]: ordem})
# Painel novo (Vite/Svelte em web/dist); as telas antigas seguem nos caminhos próprios.
painel = Path("web/dist")
if caminho in ("/", "") and (painel / "index.html").is_file():
self.path = "/web/dist/index.html"
return super().do_GET()
if caminho.startswith("/assets/"):
nome = caminho[len("/assets/"):]
if "/" in nome or "\\" in nome or nome.startswith(".") or not (painel / "assets" / nome).is_file():
return self.json({"erro": "Arquivo não disponível"}, 404)
self.path = "/web/dist/assets/" + nome
return super().do_GET()
if caminho in ("/dashboard", "/emojis", "/api"):
self.path = caminho + ".html"
@@ -201,60 +328,103 @@ class Handler(SimpleHTTPRequestHandler):
elif caminho in ('/dashboard.html','/emojis.html','/api.html','/dashboard.css','/dashboard.js','/overview.js','/biblioteca.js','/api-painel.js','/painel.css','/conversa.css','/treinamento.css','/servos.css','/braco.css','/audio-robot.js'):
pass
elif caminho not in ('/conversa.html', '/treinamento.html', '/treinamento.js', '/reacoes-render.js', '/painel-conversa.js', '/braco-calibracao.js', '/movimentos-core.js', '/braco-movimentos.js', '/braco.html', '/braco.js', '/servos.html', '/servos.js', '/carinha.html', '/camera.html', '/painel.html', '/painel-controle.js', '/painel-video.js',
'/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js', '/audio-robot.js'):
'/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js'):
return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404)
return super().do_GET()
def do_POST(self):
if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "POST"):return
if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'POST'):
if self.acesso_negado("POST"):
return
if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'POST'):
for modulo in self.modulos():
if modulo.rotas(self, "POST"):
return
if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'POST'):
return
if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'POST'):
return
if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'POST'):
return
if self.server.controle.rotas(self, 'POST'):
return
rota = self.path.split("?", 1)[0].rstrip("/")
tamanho = int(self.headers.get("Content-Length") or 0)
if rota == '/app/recarregar':
origin=self.headers.get('Origin')
if origin and origin not in ('http://'+self.headers.get('Host',''),'https://'+self.headers.get('Host','')):
return self.enviar(json.dumps(dict(erro='Origem inválida')),codigo=400)
import time
with self.server.controle.lock:
online=time.monotonic()-self.server.controle.status_at<5
import uuid
self.server.controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False)
if not online:return self.enviar(json.dumps(dict(erro='Abra o Carinha e conecte ao servidor primeiro')),codigo=409)
with TRAVA:
if 'recarregar' not in FILA:FILA.append('recarregar')
return self.enviar(json.dumps(dict(ok=True,mensagem='Solicitação enviada. Aguardando o celular recarregar a tela.')))
if rota == "/config":
rota = self.rota().rstrip("/")
try:
dados = json.loads(self.rfile.read(tamanho).decode("utf-8", "ignore") or "{}")
if not isinstance(dados, dict):
raise ValueError("esperava um objeto JSON")
except Exception as e:
return self.enviar(json.dumps({"erro": "json invalido: %s" % e}), codigo=400)
ordens = aplicar_aparencia(dados)
return self.enviar(json.dumps({"ok": True, "aparencia": APARENCIA,
"ordens": ordens}))
if rota == "/comando":
return self.comando(ler_json(self, 2048))
if rota == "/parar-tudo":
ler_json(self, 2048)
return self.json(self.parar_tudo())
if rota == "/rearmar":
ler_json(self, 2048)
return self.json(self.rearmar())
if rota == '/app/recarregar':
ler_json(self, 2048)
return self.recarregar()
if rota == "/config":
return self.json({"ok": True, "ordens": aplicar_aparencia(ler_json(self, 2048)),
"aparencia": APARENCIA})
if rota in ("/gesto", "/face"):
# Obsoleto: corpo é o nome cru. Use POST /comando.
tamanho = int(self.headers.get("Content-Length") or 0)
nome = self.rfile.read(min(tamanho, 200)).decode("utf-8", "ignore")
ordem = ordem_de_comando("gesto" if rota == "/gesto" else "rosto", nome)
enfileirar(ordem)
return self.json({"ok": True, "ordem": ordem})
except ValueError as e:
return self.json({"erro": str(e)}, 400)
return self.json({"erro": "Rota POST desconhecida"}, 404)
if rota not in ("/gesto", "/face"):
return self.enviar(json.dumps({"erro": "rotas POST: /gesto, /face, /config"}), codigo=404)
corpo = self.rfile.read(tamanho).decode("utf-8", "ignore").strip().lower()
if not corpo:
return self.enviar(json.dumps({"erro": "corpo vazio"}), codigo=400)
with TRAVA:
FILA.append(corpo)
return self.enviar(json.dumps({"ok": True, "ordem": corpo}))
def comando(self, dados):
tipo, valor = dados.get("tipo"), dados.get("valor")
s = self.server
if tipo == "cor":
if not aplicar_aparencia({"cor": valor}):
raise ValueError("Cor inválida")
return self.json({"ok": True})
if tipo == "recarregar":
return self.recarregar()
if tipo in ("reacao", "parar_reacao"):
if not hasattr(s, "treinamento"):
raise ValueError("Reações indisponíveis")
if tipo == "reacao":
if type(valor) is not int:
raise ValueError("ID de reação inválido")
s.treinamento.testar(valor)
else:
with s.treinamento.lock:
s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True))
return self.json({"ok": True})
ordem = ordem_de_comando(tipo, valor)
enfileirar(ordem)
return self.json({"ok": True, "ordem": ordem})
def parar_tudo(self):
s = self.server
if hasattr(s, "braco"):
s.braco.cancelar()
if hasattr(s, "servos"):
with s.servos.lock:
s.servos.travado = True
s.servos.stop()
if hasattr(s, "treinamento"):
with s.treinamento.lock:
s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True))
FILA.limpar()
enfileirar("calma")
ESTOP.update(ativo=True, desde=time.time())
EVENTOS.publicar("estop", dict(ESTOP))
return {"ok": True, "estop": dict(ESTOP)}
def rearmar(self):
s = self.server
if hasattr(s, "servos"):
with s.servos.lock:
s.servos.travado = False
EVENTOS.publicar("motores", s.servos.motores())
ESTOP.update(ativo=False, desde=None)
EVENTOS.publicar("estop", dict(ESTOP))
return {"ok": True, "estop": dict(ESTOP)}
def recarregar(self):
controle = self.server.controle
with controle.lock:
online = time.monotonic() - controle.status_at < 5
controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False)
if not online:
return self.json({"erro": "Abra o Carinha e conecte ao servidor primeiro"}, 409)
FILA.adicionar("recarregar")
return self.json({"ok": True, "mensagem": "Solicitação enviada. Aguardando o celular recarregar a tela."})
def log_message(self, formato, *args):
# silencio por padrao (roda em segundo plano); --log liga o registro
@@ -270,6 +440,13 @@ class Servidor(ThreadingHTTPServer):
so o desligamos no Windows."""
daemon_threads = True
allow_reuse_address = (os.name != "nt")
token = None
def ligar_eventos(servidor):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"):
if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar
def main():
@@ -282,21 +459,24 @@ def main():
os.chdir(args.pasta)
carregar_aparencia()
servidor = Servidor(("0.0.0.0", args.porta), Handler)
servidor.token = carregar_token(Path.cwd())
servidor.api_config = ConfigAPI(Path.cwd() / 'api-privada.json')
servidor.controle = ControlePC(Path.cwd() / 'app-config.json')
servidor.servos = ControleServos(Path.cwd() / 'servos.json')
servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos)
def expressao_ia(gesto):
with TRAVA: FILA.append(gesto)
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, expressao_ia)
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, enfileirar)
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
servidor.conversa.treinamento = servidor.treinamento
ligar_eventos(servidor)
servidor.registrar = args.log
print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/carinha.html")
print(f"painel de controle em http://0.0.0.0:{args.porta}/painel")
print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/")
print("autenticação:", "token obrigatório" if servidor.token else "DESLIGADA (defina CARINHA_TOKEN)")
try:
servidor.serve_forever()
finally:
EVENTOS.fechar()
if __name__ == "__main__":
+29 -12
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path
import threading
import time
from comum import Conflito, gravar_atomico, ler_json
def integer(value, lo, hi):
if type(value) is not int or not lo <= value <= hi:
@@ -46,6 +47,9 @@ class ControleServos:
self.hw = hardware
self.error = ''
self.active = {}
self.dono = None # 'braco' ou 'diagnostico': quem está com os motores
self.travado = False # E-Stop: nada se move até rearmar
self.publicar = lambda tipo, dados: None
self.closed = threading.Event()
self.config = [dict(nome=f'Servo {i+1}', canal=i, minimo=1400, maximo=1600, centro=1500) for i in range(6)]
try:
@@ -78,14 +82,24 @@ class ControleServos:
def status(self):
with self.lock:
return dict(disponivel=self.hw is not None, erro=self.error,
servos=[dict(row) for row in self.config], ativos=list(self.active), prazo=2)
servos=[dict(row) for row in self.config], ativos=list(self.active), prazo=2,
dono=self.dono, travado=self.travado)
def motores(self):
with self.lock:
return dict(disponivel=self.hw is not None, erro=self.error, ativos=sorted(self.active),
dono=self.dono, travado=self.travado)
def reservar(self, nome):
"""Braço e diagnóstico usam o mesmo PCA9685 com limites diferentes: um de cada vez."""
with self.lock:
if self.travado: raise Conflito('E-Stop ativo: rearme antes de mover')
if self.dono not in (None, nome) and self.active:
raise Conflito(f'Motores em uso por {self.dono}; pare-os primeiro')
self.dono = nome
def save(self, rows):
rows = self.validate(rows)
with self.lock:
if self.active: raise ValueError('Desligue os sinais antes de salvar')
tmp = self.path.with_suffix('.tmp')
tmp.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8')
tmp.replace(self.path)
gravar_atomico(self.path, rows)
self.config = rows
return self.status()
def move(self, index, pulse):
@@ -94,20 +108,28 @@ class ControleServos:
row = self.config[index]
pulse = integer(pulse, row['minimo'], row['maximo'])
if self.hw is None: raise OSError('PCA9685 indisponível neste servidor')
self.reservar('diagnostico')
self.hw.pulse(row['canal'], pulse)
self.active[row['canal']] = time.monotonic() + 2
return self.status()
def expire(self, now):
with self.lock:
liberou = False
for channel, deadline in list(self.active.items()):
if now >= deadline:
self.hw.off(channel)
del self.active[channel]
liberou = True
if liberou and not self.active:
self.dono = None
self.publicar('motores', self.motores())
def stop(self):
with self.lock:
if self.hw:
for channel in range(16): self.hw.off(channel)
self.active.clear()
self.dono = None
self.publicar('motores', self.motores())
return self.status()
def watch(self):
while not self.closed.wait(.1):
@@ -122,14 +144,7 @@ class ControleServos:
try:
if method == 'GET' and route == '/servos/status': result = self.status()
elif method == 'POST':
origin = h.headers.get('Origin')
if origin and origin not in ('http://' + h.headers.get('Host', ''), 'https://' + h.headers.get('Host', '')):
raise ValueError('Origem não permitida')
if h.headers.get('Content-Type', '').split(';')[0] != 'application/json': raise ValueError('Use JSON')
size = int(h.headers.get('Content-Length', '0'))
if not 0 < size <= 8192: raise ValueError('Corpo inválido')
data = json.loads(h.rfile.read(size))
if not isinstance(data, dict): raise ValueError('Objeto JSON obrigatório')
data = ler_json(h, 8192)
if route == '/servos/config': result = self.save(data.get('servos'))
elif route == '/servos/mover': result = self.move(data.get('indice'), data.get('pulso'))
elif route == '/servos/parar': result = self.stop()
@@ -138,6 +153,8 @@ class ControleServos:
h.enviar(json.dumps({'erro':'Método não permitido'}), codigo=405)
return True
h.enviar(json.dumps(result, ensure_ascii=False))
except Conflito as e:
h.enviar(json.dumps({'erro':str(e)}, ensure_ascii=False), codigo=409)
except (ValueError, TypeError) as e:
h.enviar(json.dumps({'erro':str(e)}), codigo=400)
except OSError as e:
+3 -3
View File
@@ -46,7 +46,7 @@ class ControleTest(unittest.TestCase):
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.url = 'http://127.0.0.1:%d' % self.server.server_port
kiosk.FILA.clear()
kiosk.FILA.limpar()
def tearDown(self):
self.server.shutdown()
@@ -64,12 +64,12 @@ class ControleTest(unittest.TestCase):
return e.code, e.read()
def test_config_is_persisted_without_consuming_gestures(self):
kiosk.FILA.append('feliz')
kiosk.FILA.adicionar('feliz')
code, body = self.request('/app/config', {'volume': 0.7, 'sompausa': 40})
self.assertEqual(code, 200)
self.assertEqual(json.loads(body)['config']['volume'], 0.7)
self.assertEqual(self.request('/app/config')[0], 200)
self.assertEqual(kiosk.FILA, ['feliz'])
self.assertEqual(kiosk.FILA.ordens(), ['feliz'])
self.assertEqual(json.loads((Path(self.tmp.name) / 'app.json').read_text())['sompausa'], 40)
def test_invalid_configuration_does_not_partially_apply(self):
+177
View File
@@ -0,0 +1,177 @@
"""Painel novo (web/dist) contra o servidor real: token, SSE, gestos, paleta, E-Stop, ajustes e celular.
Requer `npm run build` em web/. Uso: python tests/test_painel_web_browser.py [pasta-para-capturas]
"""
from playwright.sync_api import sync_playwright, expect
from pathlib import Path
import importlib.util
import os
import sys
import tempfile
import threading
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)
spec = importlib.util.spec_from_file_location('kiosk_web', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
from braco import Braco
from cerebro import Cerebro
from conversa import Conversa
from servos import ControleServos
from treinamento import Treinamento
TOKEN = 'token-de-teste-123'
class HW:
def __init__(self): self.calls = []
def pulse(self, ch, us): self.calls.append((ch, us))
def off(self, ch): self.calls.append((ch, None))
def main(capturas=None):
assert (ROOT / 'web/dist/index.html').is_file(), 'Rode npm run build em web/ primeiro'
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
kiosk.ARQUIVO_APARENCIA = str(tmp / 'aparencia.json')
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
server.token = TOKEN
server.controle = kiosk.ControlePC(tmp / 'app.json')
server.servos = ControleServos(tmp / 'servos.json', HW())
server.braco = Braco(tmp / 'braco.json', server.servos)
server.cerebro = Cerebro(tmp / 'ia.json', server.controle, kiosk.enfileirar)
server.conversa = Conversa(tmp / 'memoria.db', server.cerebro)
server.treinamento = Treinamento(tmp / 'treinamento.db', iniciar=False)
server.conversa.treinamento = server.treinamento
kiosk.ligar_eventos(server)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
testar('http://127.0.0.1:%d' % server.server_port, server, capturas)
finally:
kiosk.EVENTOS.fechar()
server.shutdown()
server.server_close()
server.servos.close()
def testar(base, server, capturas):
auth = {'Authorization': 'Bearer ' + TOKEN}
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': 1440, 'height': 960})
erros = []
page.on('pageerror', lambda e: erros.append(str(e)))
page.goto(base + '/')
# 1. token
expect(page.get_by_role('heading', name='Token do Carinha')).to_be_visible()
page.get_by_label('Token').fill(TOKEN)
page.get_by_role('button', name='Entrar').click()
expect(page.get_by_text('Servidor', exact=True)).to_be_visible(timeout=5000)
# 2. telemetria do celular chega por SSE
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
'microfone_ativo': True, 'microfone': 0.3})
expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
# 3. gesto pelo dock
kiosk.FILA.limpar()
page.get_by_role('button', name='Feliz').first.click()
expect(page.get_by_text('Expressão: feliz')).to_be_visible(timeout=5000)
assert kiosk.FILA.ordens() == ['feliz'], kiosk.FILA.ordens()
# 4. atalho numérico e paleta
page.locator('body').click(position={'x': 5, 'y': 900})
page.keyboard.press('2')
page.wait_for_timeout(300)
assert kiosk.FILA.ordens()[-1] == 'coracao', kiosk.FILA.ordens()
page.keyboard.press('Control+k')
page.get_by_label('Buscar comando').fill('surpreso')
page.keyboard.press('Enter')
page.wait_for_timeout(300)
assert kiosk.FILA.ordens()[-1] == 'surpreso', kiosk.FILA.ordens()
if capturas:
page.screenshot(path=str(Path(capturas) / 'ao-vivo.png'), full_page=True)
# 5. E-Stop real
page.get_by_role('button', name='Parar tudo').click()
expect(page.get_by_role('button', name='PARADO · Rearmar')).to_be_visible(timeout=5000)
assert kiosk.ESTOP['ativo'] and server.servos.travado
page.get_by_role('button', name='PARADO · Rearmar').click()
expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=5000)
assert not server.servos.travado
# 6. ajustes salvam sem travar
page.get_by_role('link', name='Ajustes').first.click()
page.get_by_label('Buscar ajuste').fill('boca')
boca = page.get_by_role('switch', name='Mostrar boca')
assert boca.is_checked()
boca.locator('xpath=..').click()
page.wait_for_timeout(500)
assert server.controle.config['boca'] is False
if capturas:
page.get_by_label('Buscar ajuste').fill('')
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
# 6b. conversa: pergunta de hora é respondida pelo relógio (sem IA) e chega por SSE
page.get_by_role('link', name='Conversa').first.click()
page.get_by_label('Mensagem').fill('Que horas são?')
page.get_by_role('button', name='Enviar').click()
expect(page.get_by_text('no horário de São Paulo')).to_be_visible(timeout=5000)
page.get_by_label('Nova lembrança').fill('Meu nome é Júlio')
page.get_by_role('button', name='Adicionar').click()
expect(page.get_by_text('Meu nome é Júlio')).to_be_visible(timeout=5000)
page.get_by_role('tab', name='Rotina e preferências').click()
page.get_by_label('Nova rotina').fill('Treino às 7h')
page.get_by_role('button', name='Adicionar').click()
expect(page.get_by_text('Treino às 7h')).to_be_visible(timeout=5000)
if capturas:
page.screenshot(path=str(Path(capturas) / 'conversa.png'), full_page=True)
# 6c. reações: criar emoji, mostrar no celular, excluir e desfazer pelo toast
page.get_by_role('link', name='Reações').first.click()
page.get_by_role('button', name='Novo emoji').click()
page.get_by_label('Nome').fill('Ideia brilhante')
page.get_by_label('Quando usar').fill('Quando tiver uma ideia')
page.get_by_role('radio', name='🚀').click()
page.get_by_role('button', name='Salvar').click()
cartao = page.locator('li.reacao', has_text='Ideia brilhante')
expect(cartao).to_be_visible(timeout=5000)
cartao.get_by_role('button', name='Mostrar').click()
page.wait_for_timeout(400)
assert server.treinamento.expressao['receita']['nome'] == 'Ideia brilhante'
cartao.get_by_role('button', name='Excluir').click()
expect(cartao).to_have_count(0, timeout=5000)
page.get_by_role('button', name='Desfazer').last.click()
expect(page.locator('li.reacao', has_text='Ideia brilhante')).to_be_visible(timeout=5000)
if capturas:
page.screenshot(path=str(Path(capturas) / 'reacoes.png'), full_page=True)
# 7. celular: barra inferior e nada de rolagem horizontal
page.set_viewport_size({'width': 400, 'height': 860})
page.get_by_role('link', name='Ao vivo').click()
page.wait_for_timeout(300)
largura = page.evaluate('document.scrollingElement.scrollWidth')
assert largura <= 400, 'rolagem horizontal: %s' % largura
if capturas:
page.screenshot(path=str(Path(capturas) / 'celular.png'), full_page=True)
# 8. tema claro
page.set_viewport_size({'width': 1280, 'height': 900})
inicial = page.evaluate("document.documentElement.dataset.theme")
outro = 'light' if inicial == 'dark' else 'dark'
page.get_by_role('button', name='Usar tema ' + ('claro' if outro == 'light' else 'escuro')).click()
assert page.evaluate("document.documentElement.dataset.theme") == outro
if capturas:
page.screenshot(path=str(Path(capturas) / ('tema-' + outro + '.png')))
assert not erros, erros
browser.close()
print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, conversa, memória, reações com desfazer, celular 400px, tema')
if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else None)
+226
View File
@@ -0,0 +1,226 @@
"""Servidor base: token, /eventos (SSE), /estado, /comando, E-Stop, trava de hardware e fila."""
import http.client
import importlib.util
import json
import sys
import tempfile
import threading
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
spec = importlib.util.spec_from_file_location('kiosk', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
from braco import Braco
from comum import carregar_token
from servos import ControleServos
TOKEN = 'segredo-de-teste'
class HW:
def __init__(self): self.calls = []
def pulse(self, ch, us): self.calls.append((ch, us))
def off(self, ch): self.calls.append((ch, None))
class ServidorTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
pasta = Path(self.tmp.name)
self.hw = HW()
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
self.server.token = TOKEN
self.server.controle = kiosk.ControlePC(pasta / 'app.json')
self.server.servos = ControleServos(pasta / 'servos.json', self.hw)
self.server.braco = Braco(pasta / 'braco.json', self.server.servos)
kiosk.ligar_eventos(self.server)
kiosk.FILA.limpar()
kiosk.ESTOP.update(ativo=False, desde=None)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.porta = self.server.server_port
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.server.servos.close()
self.thread.join()
self.tmp.cleanup()
def pedir(self, metodo, rota, dados=None, token=TOKEN, cabecalhos=None):
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
h = {'Content-Type': 'application/json'}
if token:
h['Authorization'] = 'Bearer ' + token
h.update(cabecalhos or {})
corpo = None if dados is None else json.dumps(dados)
conn.request(metodo, rota, body=corpo, headers=h)
r = conn.getresponse()
texto = r.read()
conn.close()
return r.status, json.loads(texto) if texto else None
# ------------------------------------------------------------- token
def test_rotas_de_controle_exigem_token(self):
for metodo, rota, dados in [('POST', '/braco/mover', {'angulos': [90] * 7}),
('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'}),
('POST', '/parar-tudo', {}),
('GET', '/estado', None), ('GET', '/fila?aparelho=1', None),
('GET', '/app/status', None), ('GET', '/gesto/feliz', None)]:
for token in (None, 'errado'):
codigo, corpo = self.pedir(metodo, rota, dados, token=token)
self.assertEqual(codigo, 401, (metodo, rota, token))
self.assertEqual(self.hw.calls, [])
self.assertEqual(kiosk.FILA.ordens(), [])
def test_rotas_publicas_e_token_na_query(self):
self.assertEqual(self.pedir('GET', '/gestos', token=None)[0], 200)
self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200)
self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200)
def test_sem_token_configurado_fica_aberto(self):
self.server.token = None
self.assertEqual(self.pedir('GET', '/estado', token=None)[0], 200)
def test_carregar_token(self):
pasta = Path(self.tmp.name)
self.assertIsNone(carregar_token(pasta))
(pasta / 'seguranca.json').write_text(json.dumps({'token': 'abcdefgh123'}))
self.assertEqual(carregar_token(pasta), 'abcdefgh123')
(pasta / 'seguranca.json').write_text(json.dumps({'token': 'curto'}))
with self.assertRaises(ValueError):
carregar_token(pasta)
def test_origem_estrangeira_recusada(self):
codigo, _ = self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'},
cabecalhos={'Origin': 'http://malicioso.example'})
self.assertEqual(codigo, 400)
# ------------------------------------------------------------- comando e fila
def test_comando_valida_e_enfileira(self):
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'miudo'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
def test_post_face_usa_prefixo_rosto(self):
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN})
self.assertEqual(conn.getresponse().status, 200)
conn.close()
self.assertEqual(kiosk.FILA.ordens(), ['rosto miudo'])
def test_fila_limitada_e_com_validade(self):
fila = kiosk.Fila(maximo=3, validade=10)
for i in range(5):
fila.adicionar(f'g{i}', agora=0)
self.assertEqual(fila.ordens(), ['g2', 'g3', 'g4'])
self.assertEqual(fila.consumir(agora=11), [])
fila.adicionar('recarregar'); fila.adicionar('recarregar'); fila.adicionar('feliz')
self.assertEqual(fila.ordens(), ['recarregar', 'feliz'])
# Rosto no navegador não rouba o 'recarregar' do celular.
self.assertEqual(fila.consumir(aparelho=False), ['feliz'])
self.assertEqual(fila.consumir(aparelho=True), ['recarregar'])
# ------------------------------------------------------------- SSE
def test_eventos_entrega_comando(self):
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
conn.request('GET', '/eventos?t=' + TOKEN)
r = conn.getresponse()
self.assertEqual(r.status, 200)
self.assertIn('text/event-stream', r.getheader('Content-Type'))
linhas = []
while not linhas or linhas[-1] != '':
linhas.append(r.fp.readline().decode().rstrip('\n'))
self.assertIn('event: pronto', linhas)
self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'coracao'})
bloco = []
while not bloco or bloco[-1] != '':
bloco.append(r.fp.readline().decode().rstrip('\n'))
conn.close()
self.assertIn('event: expressao', bloco)
self.assertIn('data: {"ordem": "coracao"}', bloco)
def test_estado_agrega(self):
codigo, estado = self.pedir('GET', '/estado')
self.assertEqual(codigo, 200)
for chave in ('versao', 'seq', 'estop', 'aparencia', 'gestos', 'app', 'motores'):
self.assertIn(chave, estado)
self.assertTrue(estado['autenticacao'])
# ------------------------------------------------------------- E-Stop e trava
def test_parar_tudo_desliga_e_trava_ate_rearmar(self):
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
self.hw.calls.clear()
codigo, corpo = self.pedir('POST', '/parar-tudo', {})
self.assertEqual(codigo, 200)
self.assertTrue(corpo['estop']['ativo'])
self.assertEqual({c for c, us in self.hw.calls if us is None}, set(range(16)))
self.assertEqual(kiosk.FILA.ordens(), ['calma'])
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 409)
self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 409)
self.assertEqual(self.pedir('POST', '/rearmar', {})[0], 200)
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
def test_braco_e_diagnostico_nao_disputam_motores(self):
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
codigo, corpo = self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})
self.assertEqual(codigo, 409)
self.assertIn('braco', corpo['erro'])
self.pedir('POST', '/braco/parar', {})
self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 200)
class BracoMovimentoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.hw = HW()
self.servos = ControleServos(Path(self.tmp.name) / 's.json', self.hw)
self.servos.closed.set() # sem threads: os passos são chamados à mão
self.arm = Braco(Path(self.tmp.name) / 'b.json', self.servos)
cal = self.arm.status()
for row in cal['calibracao']:
row.update(minimo=500, maximo=2500, centro=1500)
self.arm.save(dict(calibracao=cal['calibracao'], posturas=[]))
def tearDown(self):
self.tmp.cleanup()
def test_interpolacao_no_servidor_respeita_velocidade(self):
self.arm.move([90] * 7)
resposta = self.arm.move([90, 90, 90, 90, 90, 90, 130], velocidade=100, ttl_ms=2000)
self.assertTrue(resposta['movendo'])
agora = self.arm.lease - 1.5
self.arm.passo(agora, .1) # 100 °/s * 0,1 s = 10°
self.assertEqual(self.arm.atual[6], 100)
for i in range(10):
self.arm.passo(agora + .1 * (i + 1), .1)
self.assertEqual(self.arm.atual[6], 130)
self.assertIsNone(self.arm.alvo)
def test_lease_vencido_interrompe_movimento(self):
self.arm.move([90] * 7)
self.arm.move([90] * 6 + [170], velocidade=50, ttl_ms=300)
self.arm.passo(self.arm.lease + .01, .1)
self.assertIsNone(self.arm.alvo)
self.assertEqual(self.arm.atual[6], 90)
# O watchdog desliga os canais no mesmo prazo.
self.servos.expire(self.arm.lease + .01)
self.assertEqual(self.servos.active, {})
self.assertIsNone(self.servos.dono)
def test_parametros_invalidos(self):
for kwargs in (dict(velocidade=1000), dict(velocidade=True), dict(ttl_ms=50), dict(ttl_ms=5000)):
with self.assertRaises(ValueError):
self.arm.move([90] * 7, **kwargs)
self.assertEqual(self.hw.calls, [])
if __name__ == '__main__':
unittest.main()
+15 -7
View File
@@ -1,6 +1,7 @@
"""Rotina, diário e reações procedurais. Não treina pesos nem controla motores."""
import json,sqlite3,threading,time,math
from contextlib import contextmanager
from comum import ler_json
from gemini import gerar_json
from figurinhas import criar_desenho,validar_desenho,montar_prompt
@@ -28,8 +29,20 @@ def validar_receita(r):
return r
class Treinamento:
# Mudanças de expressão/ocupação viram eventos SSE sem caçar cada atribuição.
@property
def expressao(self):return self._expressao
@expressao.setter
def expressao(self,valor):
self._expressao=valor;self.publicar('reacao',valor)
@property
def busy(self):return self._busy
@busy.setter
def busy(self,valor):
self._busy=valor;self.publicar('treinamento',dict(ocupado=valor,erro=self.erro))
def __init__(self,path,iniciar=True):
self.path=str(path);self.lock=threading.RLock();self.busy=False;self.erro='';self.expressao=None
self.publicar=lambda tipo,dados:None
self.path=str(path);self.lock=threading.RLock();self.erro='';self.busy=False;self.expressao=None
with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS rotina(id INTEGER PRIMARY KEY,texto TEXT)')
db.execute('CREATE TABLE IF NOT EXISTS diario(id INTEGER PRIMARY KEY,horario REAL,papel TEXT,texto TEXT)')
@@ -178,12 +191,7 @@ class Treinamento:
elif method=='GET' and route=='/treinamento/expressao':
with self.lock:result=self.expressao
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
size=int(h.headers.get('Content-Length','0'))
if not 0<size<=4096 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 4096 bytes')
d=json.loads(h.rfile.read(size))
if not isinstance(d,dict):raise ValueError('Objeto obrigatório')
d=ler_json(h,4096)
with self.lock,self.db() as db:
if route=='/treinamento/biblioteca':self.alterar_reacao(d.get('acao'),d.get('id'),d)
elif route=='/treinamento/parar':self.expressao=dict(id=time.time_ns(),horario=time.time(),receita=dict(limpar=True))
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b1017" />
<title>Carinha</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230b1017'/%3E%3Crect x='14' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3Crect x='38' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3C/svg%3E" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1250
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "carinha-painel",
"private": true,
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.4",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^8.3.2"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@lucide/svelte": "^1.52.0"
}
}
+364
View File
@@ -0,0 +1,364 @@
<script lang="ts">
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte';
import MiniRosto from './lib/componentes/MiniRosto.svelte';
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 { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.svelte';
import { nav, ROTAS, type Rota } from './lib/rotas.svelte';
import AoVivo from './telas/AoVivo.svelte';
import Ajustes from './telas/Ajustes.svelte';
import Conversa from './telas/Conversa.svelte';
import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte';
const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo,
conversa: Conversa,
reacoes: Reacoes,
movimento: Movimento,
ajustes: Ajustes,
};
const ICONES: Record<Rota, Component<any>> = {
'ao-vivo': Video,
conversa: MessageCircle,
reacoes: Smile,
movimento: Hand,
ajustes: Settings,
};
const Tela = $derived(TELAS[nav.atual]);
// ---------- tema
let tema = $state<'dark' | 'light'>('dark');
function aplicarTema(t: 'dark' | 'light') {
tema = t;
document.documentElement.dataset.theme = t;
try {
localStorage.setItem('carinha_tema', t);
} catch {
/* ok */
}
}
// ---------- atalhos: Ctrl+K paleta · 1–9 gestos favoritos · Shift+Espaço ou Esc Esc = E-Stop · g+letra navega
let ultimoEsc = 0;
let prefixoG = 0;
function editando(e: KeyboardEvent) {
const alvo = e.target as HTMLElement;
return alvo.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(alvo.tagName);
}
function teclado(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
paleta.aberta ? paleta.fechar() : paleta.abrir();
return;
}
if (e.shiftKey && e.code === 'Space' && !editando(e)) {
e.preventDefault();
pararTudo();
return;
}
if (e.key === 'Escape' && !paleta.aberta) {
const agora = Date.now();
if (agora - ultimoEsc < 600) pararTudo();
ultimoEsc = agora;
return;
}
if (editando(e) || paleta.aberta || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === 'g') {
prefixoG = Date.now();
return;
}
if (Date.now() - prefixoG < 800) {
const rota = ROTAS.find((r) => r.atalho === 'g ' + e.key);
prefixoG = 0;
if (rota) nav.ir(rota.id);
return;
}
if (/^[1-9]$/.test(e.key)) {
const g = favoritos.lista[Number(e.key) - 1];
if (g) gesto(g);
}
}
onMount(() => {
let salvo: string | null = null;
try {
salvo = localStorage.getItem('carinha_tema');
} catch {
/* ok */
}
aplicarTema(salvo === 'light' || (!salvo && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark');
conectar();
});
const conexaoTexto = $derived(
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
);
const bateria = $derived.by(() => {
const b = estado.app?.estado?.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
</script>
<svelte:window onkeydown={teclado} />
{#if estado.conexao === 'token'}
<Token />
{/if}
<div class="app">
<nav class="lateral" aria-label="Seções">
<div class="marca">
<span class="logo" aria-hidden="true"><Bot size={20} /></span>
<div>
<strong>Carinha</strong>
<small class="fraco mono">{estado.versao ? 'v' + estado.versao : ''}</small>
</div>
</div>
<ul>
{#each ROTAS as r (r.id)}
{@const Icone = ICONES[r.id]}
<li>
<a href={'#/' + r.id} aria-current={nav.atual === r.id ? 'page' : undefined}>
<Icone size={20} /> <span>{r.titulo}</span>
</a>
</li>
{/each}
</ul>
<button class="btn fantasma paleta-btn" onclick={paleta.abrir}>
<Command size={16} /> Comandos <kbd>Ctrl K</kbd>
</button>
</nav>
<header class="topo">
<h1 class="titulo">{ROTAS.find((r) => r.id === nav.atual)?.titulo}</h1>
<div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto}
</Selo>
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
</Selo>
{#if estado.conversa}
<Selo tom="destaque">IA: {estado.conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'}</Selo>
{/if}
</div>
<div class="acoes linha">
<button class="btn fantasma icone so-celular" onclick={paleta.abrir} aria-label="Comandos">
<Command size={18} />
</button>
<button
class="btn fantasma icone"
onclick={() => aplicarTema(tema === 'dark' ? 'light' : 'dark')}
aria-label={tema === 'dark' ? 'Usar tema claro' : 'Usar tema escuro'}
>
{#if tema === 'dark'}<Sun size={18} />{:else}<Moon size={18} />{/if}
</button>
<EStop />
</div>
</header>
<main class="conteudo">
<Tela />
</main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'}
<MiniRosto />
{/if}
</div>
<Paleta />
<Avisos />
<style>
.app {
min-height: 100dvh;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: var(--topo-altura) 1fr;
grid-template-areas: 'topo' 'conteudo';
}
/* ---------- navegação: barra inferior no celular, lateral a partir de 1024px */
.lateral {
position: fixed;
z-index: 60;
left: 0;
right: 0;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: var(--vidro);
backdrop-filter: blur(16px);
border-top: 1px solid var(--borda);
}
.marca,
.paleta-btn {
display: none;
}
ul {
list-style: none;
margin: 0;
padding: var(--e-1);
display: grid;
grid-template-columns: repeat(5, 1fr);
}
li a {
display: grid;
justify-items: center;
gap: 2px;
padding: var(--e-2) 0;
border-radius: var(--raio);
color: var(--texto-3);
text-decoration: none;
font-size: 11px;
font-weight: 500;
}
li a[aria-current='page'] {
color: var(--destaque);
}
.topo {
grid-area: topo;
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: var(--e-3);
padding: 0 var(--e-4);
padding-top: env(safe-area-inset-top, 0px);
height: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
background: var(--vidro);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--borda);
}
.titulo {
font-size: var(--t-lg);
flex: none;
}
.status {
flex: 1;
min-width: 0;
overflow: hidden;
flex-wrap: nowrap;
}
.status :global(.selo:nth-child(n + 2)) {
display: none;
}
.acoes {
flex-wrap: nowrap;
}
.conteudo {
grid-area: conteudo;
padding: var(--e-4);
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
min-width: 0;
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
kbd {
margin-left: auto;
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
border: 1px solid var(--borda);
border-radius: 4px;
padding: 1px 5px;
}
@media (min-width: 640px) {
.status :global(.selo:nth-child(n + 2)) {
display: inline-flex;
}
.conteudo {
padding: var(--e-5);
padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px));
}
}
@media (min-width: 1024px) {
.app {
grid-template-columns: var(--nav-largura) 1fr;
grid-template-areas: 'nav topo' 'nav conteudo';
/* a coluna da navegação continua pintada quando a página rola além da viewport */
background: linear-gradient(
to right,
var(--fundo-2) calc(var(--nav-largura) - 1px),
var(--borda) 0 var(--nav-largura),
transparent 0
);
}
.lateral {
grid-area: nav;
position: sticky;
top: 0;
bottom: auto;
height: 100dvh;
display: flex;
flex-direction: column;
gap: var(--e-4);
padding: var(--e-4) var(--e-3);
border-top: 0;
border-right: 1px solid var(--borda);
background: var(--fundo-2);
backdrop-filter: none;
}
.marca {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2);
}
.marca div {
display: grid;
line-height: 1.2;
}
.logo {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--raio);
background: var(--destaque-suave);
color: var(--destaque);
}
ul {
grid-template-columns: 1fr;
gap: 2px;
padding: 0;
}
li a {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2) var(--e-3);
font-size: var(--t-sm);
color: var(--texto-2);
}
li a:hover {
background: var(--superficie);
color: var(--texto);
}
li a[aria-current='page'] {
background: var(--destaque-suave);
color: var(--destaque);
}
.paleta-btn {
display: flex;
margin-top: auto;
justify-content: flex-start;
color: var(--texto-2);
}
.so-celular {
display: none;
}
.conteudo {
padding-bottom: var(--e-6);
}
}
</style>
+107
View File
@@ -0,0 +1,107 @@
/** Ajustes do app do celular (/app/config): salvamento otimista e campos agrupados. */
import { pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte';
import type { AppConfig } from './tipos';
/** Atualiza na hora; se o servidor recusar, volta ao valor anterior e avisa. Não trava o formulário. */
export async function salvarApp(parcial: AppConfig) {
const antes = estado.appConfig ? { ...estado.appConfig } : null;
if (estado.appConfig) estado.appConfig = { ...estado.appConfig, ...parcial };
try {
const r = await postar<{ config: AppConfig }>('/app/config', parcial);
estado.appConfig = r.config;
} catch (e) {
estado.appConfig = antes;
avisos.erro(e);
}
}
export async function restaurarPadrao() {
const padrao = await pedir<AppConfig>('/app/padrao');
// A transmissão não é "ajuste": continua como está.
delete padrao.preview;
await salvarApp(padrao);
avisos.ok('Ajustes do celular restaurados para o padrão.');
}
type Opcoes = [string | number, string][];
export interface Campo {
chave: string;
rotulo: string;
ajuda?: string;
tipo: 'toggle' | 'slider' | 'escolha';
min?: number;
max?: number;
passo?: number;
unidade?: string;
opcoes?: Opcoes;
}
export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = [
{
titulo: 'Tela e personalidade',
descricao: 'Como o rosto aparece e se comporta sozinho.',
campos: [
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
],
},
{
titulo: 'Sensores e reações',
descricao: 'O que o celular percebe e como reage.',
campos: [
{ chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' },
{ chave: 'ouvir', rotulo: 'Reagir a sons', tipo: 'toggle' },
{ chave: 'micSono', rotulo: 'Microfone durante o sono', tipo: 'toggle' },
{ chave: 'sompausa', rotulo: 'Silêncio para dormir', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' s' },
{ chave: 'sensorCamera', rotulo: 'Câmera como sensor', ajuda: 'Detecta movimento e permite o vídeo', tipo: 'toggle' },
{ chave: 'reagir', rotulo: 'Reagir a movimento', tipo: 'toggle' },
{ chave: 'cameraSono', rotulo: 'Câmera durante o sono', tipo: 'toggle' },
{ chave: 'movolhar', rotulo: 'Quanto os olhos acompanham', tipo: 'slider', min: 0, max: 100, passo: 1, unidade: '%' },
],
},
{
titulo: 'Vídeo e rastreamento',
descricao: 'Qualidade da transmissão e rastreamento feito neste PC.',
campos: [
{ chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] },
{
chave: 'videoResolucao',
rotulo: 'Resolução',
tipo: 'escolha',
opcoes: [['auto', 'Automática'], ['max', 'Máxima'], ['480', '480p'], ['720', '720p'], ['1080', '1080p'], ['2160', '4K'], ['4320', '8K']],
},
{
chave: 'videoFPS',
rotulo: 'Quadros por segundo',
tipo: 'escolha',
opcoes: [[0, 'Automático'], [-1, 'Máximo'], [15, '15'], [24, '24'], [30, '30'], [60, '60'], [120, '120'], [240, '240']],
},
{ chave: 'rastrearRosto', rotulo: 'Rastrear rosto', tipo: 'toggle' },
{ chave: 'rastrearMaos', rotulo: 'Rastrear mãos', tipo: 'toggle' },
{ chave: 'seguirRastreamento', rotulo: 'Olhos seguem o alvo', tipo: 'toggle' },
{ chave: 'mostrarRastreamento', rotulo: 'Desenhar rastreamento no vídeo', tipo: 'toggle' },
],
},
{
titulo: 'Sensibilidade (avançado)',
descricao: 'Limiares dos sustos e intervalos entre reações.',
campos: [
{ chave: 'somlim', rotulo: 'Nível para perceber som', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'somaltolim', rotulo: 'Som mínimo para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'somganho', rotulo: 'Aumento súbito para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'somcooldown', rotulo: 'Intervalo entre sustos por som', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
{ chave: 'movlim', rotulo: 'Movimento mínimo', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'movforte', rotulo: 'Movimento para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'movcooldown', rotulo: 'Intervalo entre sustos por movimento', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
],
},
];
+70
View File
@@ -0,0 +1,70 @@
/** Cliente HTTP único do painel: token, JSON, tempo limite e erro legível. */
const CHAVE_TOKEN = 'carinha_token';
export class ErroApi extends Error {
constructor(public status: number, mensagem: string) {
super(mensagem);
}
}
export function lerToken(): string {
try {
return localStorage.getItem(CHAVE_TOKEN) || '';
} catch {
return '';
}
}
export function gravarToken(token: string) {
try {
if (token) localStorage.setItem(CHAVE_TOKEN, token);
else localStorage.removeItem(CHAVE_TOKEN);
} catch {
/* navegação privada: vale só nesta aba */
}
}
/** Avisado quando o servidor recusa o token (abre o diálogo de token). */
let aoNegar: () => void = () => {};
export function quandoNegado(fn: () => void) {
aoNegar = fn;
}
export async function pedir<T = any>(caminho: string, dados?: unknown, tempo = 8000): Promise<T> {
const cabecalhos: Record<string, string> = {};
const token = lerToken();
if (token) cabecalhos.Authorization = 'Bearer ' + token;
const opcoes: RequestInit = { cache: 'no-store', headers: cabecalhos, signal: AbortSignal.timeout(tempo) };
if (dados !== undefined) {
opcoes.method = 'POST';
cabecalhos['Content-Type'] = 'application/json';
opcoes.body = JSON.stringify(dados);
}
let resposta: Response;
try {
resposta = await fetch(caminho, opcoes);
} catch (e) {
const nome = (e as Error).name;
throw new ErroApi(0, nome === 'TimeoutError' ? 'O servidor demorou a responder' : 'Sem conexão com o servidor');
}
const texto = await resposta.text();
let corpo: any = null;
try {
corpo = texto ? JSON.parse(texto) : null;
} catch {
corpo = null;
}
if (!resposta.ok) {
if (resposta.status === 401) aoNegar();
throw new ErroApi(resposta.status, corpo?.erro || `Erro ${resposta.status}`);
}
return corpo as T;
}
export const postar = <T = any>(caminho: string, dados: unknown = {}) => pedir<T>(caminho, dados);
export function urlEventos(): string {
const token = lerToken();
return token ? '/eventos?t=' + encodeURIComponent(token) : '/eventos';
}
+41
View File
@@ -0,0 +1,41 @@
/** Toasts no lugar de alert/confirm: mensagem curta, com ação opcional (ex.: Desfazer). */
export type TipoAviso = 'info' | 'ok' | 'erro';
export interface Aviso {
id: number;
texto: string;
tipo: TipoAviso;
acao?: { rotulo: string; fazer: () => void };
}
class Avisos {
lista = $state<Aviso[]>([]);
private seq = 0;
mostrar(texto: string, tipo: TipoAviso = 'info', acao?: Aviso['acao'], ms = tipo === 'erro' ? 6000 : 3500) {
const id = ++this.seq;
this.lista = [...this.lista, { id, texto, tipo, acao }].slice(-4);
setTimeout(() => this.fechar(id), acao ? Math.max(ms, 6000) : ms);
return id;
}
fechar(id: number) {
this.lista = this.lista.filter((a) => a.id !== id);
}
ok = (t: string, acao?: Aviso['acao']) => this.mostrar(t, 'ok', acao);
erro = (e: unknown) => this.mostrar(e instanceof Error ? e.message : String(e), 'erro');
}
export const avisos = new Avisos();
/** Executa uma ação do painel mostrando o erro num toast em vez de engolir. */
export async function tentar<T>(fn: () => Promise<T>, sucesso?: string): Promise<T | undefined> {
try {
const r = await fn();
if (sucesso) avisos.ok(sucesso);
return r;
} catch (e) {
avisos.erro(e);
return undefined;
}
}
+89
View File
@@ -0,0 +1,89 @@
/** Ações que qualquer tela pode disparar: gestos, rosto, cor, E-Stop. */
import { postar } from './api';
import { avisos, tentar } from './avisos.svelte';
import { estado } from './estado.svelte';
/** Nome amigável e emoji de cada gesto do rosto ('parar' fecha o kiosk: não vira botão). */
export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
feliz: { nome: 'Feliz', emoji: '😊' },
alegre: { nome: 'Alegre', emoji: '😄' },
coracao: { nome: 'Coração', emoji: '❤️' },
piscar: { nome: 'Piscar', emoji: '😌' },
piscar2: { nome: 'Piscar duplo', emoji: '😑' },
piscadinha: { nome: 'Piscadinha', emoji: '😉' },
surpreso: { nome: 'Surpreso', emoji: '😮' },
triste: { nome: 'Triste', emoji: '😢' },
bravo: { nome: 'Bravo', emoji: '😠' },
olhar_esq: { nome: 'Olhar à esquerda', emoji: '👈' },
olhar_dir: { nome: 'Olhar à direita', emoji: '👉' },
olhar_baixo: { nome: 'Olhar para baixo', emoji: '👇' },
dormir: { nome: 'Dormir', emoji: '😴' },
acordar: { nome: 'Acordar', emoji: '🌅' },
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
};
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
const CHAVE_FAVORITOS = 'carinha_favoritos';
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
function lerFavoritos(): string[] {
try {
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.slice(0, 9);
} catch {
/* sem armazenamento */
}
return PADRAO_FAVORITOS;
}
class Favoritos {
lista = $state<string[]>(lerFavoritos());
alternar(g: string) {
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9);
try {
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
} catch {
/* ok */
}
}
}
export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
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 recarregarCelular = () =>
tentar(async () => {
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
avisos.ok(r.mensagem);
});
export async function pararTudo() {
try {
await postar('/parar-tudo');
avisos.mostrar('E-Stop acionado: motores desligados e travados.', 'erro');
} catch (e) {
avisos.erro(e);
}
}
export const rearmar = () => tentar(() => postar('/rearmar'), 'Motores rearmados.');
export function hex(rgb: number[]) {
return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
}
export function rgb(hexCor: string): [number, number, number] {
const n = parseInt(hexCor.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export const CORES: { nome: string; rgb: [number, number, number] }[] = [
{ nome: 'Ciano', rgb: [92, 214, 255] },
{ nome: 'Verde', rgb: [74, 222, 128] },
{ nome: 'Amarelo', rgb: [251, 191, 36] },
{ nome: 'Laranja', rgb: [255, 140, 50] },
{ nome: 'Rosa', rgb: [255, 110, 190] },
{ nome: 'Roxo', rgb: [167, 139, 250] },
{ nome: 'Vermelho', rgb: [248, 90, 90] },
{ nome: 'Branco', rgb: [235, 240, 245] },
];
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts" generics="T extends string">
let { abas, ativa = $bindable(), rotulo }: { abas: { id: T; titulo: string; contador?: number }[]; ativa: T; rotulo: string } =
$props();
function tecla(e: KeyboardEvent, i: number) {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
const prox = abas[(i + d + abas.length) % abas.length];
ativa = prox.id;
(e.currentTarget as HTMLElement).parentElement?.querySelector<HTMLElement>(`[data-aba="${prox.id}"]`)?.focus();
}
</script>
<div class="abas" role="tablist" aria-label={rotulo}>
{#each abas as aba, i (aba.id)}
<button
role="tab"
data-aba={aba.id}
aria-selected={ativa === aba.id}
tabindex={ativa === aba.id ? 0 : -1}
onclick={() => (ativa = aba.id)}
onkeydown={(e) => tecla(e, i)}
>
{aba.titulo}
{#if aba.contador !== undefined}<span class="contador">{aba.contador}</span>{/if}
</button>
{/each}
</div>
<style>
.abas {
display: flex;
gap: var(--e-1);
border-bottom: 1px solid var(--borda);
overflow-x: auto;
scrollbar-width: none;
}
button {
display: inline-flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
min-height: 40px;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: var(--texto-3);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
white-space: nowrap;
}
button[aria-selected='true'] {
color: var(--texto);
border-bottom-color: var(--destaque);
}
.contador {
font-size: var(--t-xs);
padding: 0 6px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
color: var(--texto-2);
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { avisos } from '../avisos.svelte';
</script>
<div class="avisos" role="status" aria-live="polite">
{#each avisos.lista as a (a.id)}
<div class="aviso {a.tipo}">
<span>{a.texto}</span>
{#if a.acao}
<button
class="btn pequeno"
onclick={() => {
a.acao!.fazer();
avisos.fechar(a.id);
}}>{a.acao.rotulo}</button
>
{/if}
<button class="btn fantasma pequeno icone" aria-label="Fechar aviso" onclick={() => avisos.fechar(a.id)}>
<X size={16} />
</button>
</div>
{/each}
</div>
<style>
.avisos {
position: fixed;
z-index: 90;
right: var(--e-4);
bottom: calc(var(--e-4) + env(safe-area-inset-bottom, 0px));
display: grid;
gap: var(--e-2);
width: min(420px, calc(100vw - 2 * var(--e-4)));
}
.aviso {
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-2) var(--e-2) var(--e-4);
border-radius: var(--raio);
background: var(--superficie-2);
border: 1px solid var(--borda-forte);
border-left: 4px solid var(--destaque);
box-shadow: var(--sombra-flutuante);
font-size: var(--t-sm);
}
.aviso span {
flex: 1;
}
.ok {
border-left-color: var(--ok);
}
.erro {
border-left-color: var(--perigo);
}
@media (max-width: 1023px) {
.avisos {
bottom: calc(76px + env(safe-area-inset-bottom, 0px));
}
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { titulo = '', descricao = '', acoes, children, class: classe = '' }: {
titulo?: string;
descricao?: string;
acoes?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<section class="card {classe}">
{#if titulo || acoes}
<header>
<div>
{#if titulo}<h2>{titulo}</h2>{/if}
{#if descricao}<p class="fraco pequeno-texto">{descricao}</p>{/if}
</div>
{#if acoes}<div class="linha">{@render acoes()}</div>{/if}
</header>
{/if}
{@render children()}
</section>
<style>
.card {
background: var(--superficie);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
padding: var(--e-4);
display: grid;
gap: var(--e-4);
align-content: start;
min-width: 0;
}
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--e-3);
flex-wrap: wrap;
}
h2 {
font-size: var(--t-md);
}
@media (min-width: 640px) {
.card {
padding: var(--e-5);
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { OctagonX, RotateCcw } from '@lucide/svelte';
import { pararTudo, rearmar } from '../comandos.svelte';
import { estado } from '../estado.svelte';
</script>
{#if estado.estop.ativo}
<button class="btn estop parado" onclick={rearmar} title="Motores travados pelo E-Stop. Clique para rearmar.">
<RotateCcw size={18} /> <span>PARADO · Rearmar</span>
</button>
{:else}
<button class="btn perigo estop" onclick={pararTudo} title="Parar tudo (Shift+Espaço ou Esc duas vezes)">
<OctagonX size={18} /> <span>Parar tudo</span>
</button>
{/if}
<style>
.estop {
font-weight: 700;
letter-spacing: 0.02em;
}
.parado {
background: var(--aviso-suave);
border-color: var(--aviso);
color: var(--aviso);
animation: pulso 1.6s ease-in-out infinite;
}
@keyframes pulso {
50% {
box-shadow: 0 0 0 4px var(--aviso-suave);
}
}
@media (max-width: 480px) {
span {
display: none;
}
.estop {
width: 44px;
padding: 0;
}
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { Star } from '@lucide/svelte';
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
let { completo = false }: { completo?: boolean } = $props();
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
</script>
<div class="gestos" class:completo>
{#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]}
<div class="item">
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<span class="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span>
{#if !completo}<kbd>{i + 1}</kbd>{/if}
</button>
{#if completo}
<button
class="estrela"
class:ativa={favoritos.lista.includes(g)}
aria-label={favoritos.lista.includes(g) ? `Tirar ${info.nome} dos favoritos` : `Fixar ${info.nome} nos favoritos`}
aria-pressed={favoritos.lista.includes(g)}
onclick={() => favoritos.alternar(g)}
>
<Star size={14} />
</button>
{/if}
</div>
{/each}
</div>
<style>
.gestos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: var(--e-2);
}
.item {
position: relative;
}
.gesto {
width: 100%;
display: grid;
justify-items: center;
gap: 2px;
padding: var(--e-3) var(--e-1) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
cursor: pointer;
position: relative;
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
}
.gesto:hover {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.gesto:active {
transform: scale(0.97);
}
.emoji {
font-size: 1.6rem;
line-height: 1.2;
}
.nome {
font-size: var(--t-xs);
color: var(--texto-2);
text-align: center;
}
kbd {
position: absolute;
top: 4px;
right: 6px;
font: 500 10px var(--fonte-mono);
color: var(--texto-3);
}
.estrela {
position: absolute;
top: 2px;
right: 2px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--texto-3);
cursor: pointer;
}
.estrela.ativa {
color: var(--aviso);
}
.estrela.ativa :global(svg) {
fill: currentColor;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import { ChevronDown, ChevronUp } from '@lucide/svelte';
import { estado } from '../estado.svelte';
import RostoPrevia from './RostoPrevia.svelte';
// Um só mini-rosto em todas as telas (exceto Ao vivo, que já tem o rosto grande).
let aberto = $state(true);
try {
aberto = localStorage.getItem('carinha_mini') !== '0';
} catch {
/* ok */
}
function alternar() {
aberto = !aberto;
try {
localStorage.setItem('carinha_mini', aberto ? '1' : '0');
} catch {
/* ok */
}
}
</script>
<aside class="mini" class:aberto aria-label="Rosto ao vivo">
<button class="barra" onclick={alternar} aria-expanded={aberto}>
<span class="ponto" class:online={estado.celularOnline} aria-hidden="true"></span>
<span>Rosto {estado.celularOnline ? 'ao vivo' : '· celular offline'}</span>
{#if aberto}<ChevronDown size={16} />{:else}<ChevronUp size={16} />{/if}
</button>
{#if aberto}
<div class="tela"><RostoPrevia /></div>
{/if}
</aside>
<style>
.mini {
position: fixed;
z-index: 55;
right: var(--e-4);
bottom: calc(84px + env(safe-area-inset-bottom, 0px));
width: 220px;
border-radius: var(--raio);
overflow: hidden;
background: var(--vidro);
backdrop-filter: blur(16px);
border: 1px solid var(--borda-forte);
box-shadow: var(--sombra-flutuante);
}
.barra {
width: 100%;
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
border: 0;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.barra span:nth-child(2) {
flex: 1;
text-align: left;
}
.ponto {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--texto-3);
}
.ponto.online {
background: var(--ok);
}
.tela {
aspect-ratio: 16 / 9;
}
@media (max-width: 639px) {
.mini {
width: 160px;
}
}
@media (min-width: 1024px) {
.mini {
bottom: var(--e-4);
width: 260px;
}
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { SIMBOLO_EMOJI } from '../treinamento.svelte';
// Prévia estática de uma reação (sem iframe): figurinha SVG, emoji ou olhos do 1º passo.
let { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
const passo = $derived(receita?.passos?.[0]);
const simbolo = $derived(receita?.emoji?.caractere || SIMBOLO_EMOJI[receita?.simbolo] || '');
</script>
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
{#if receita?.figurinha?.formas}
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}>
{#each receita.figurinha.formas as forma, i (i)}
<path d={forma.caminho} fill={forma.cor} />
{/each}
</svg>
{:else if receita?.emoji}
<span class="emoji">{receita.emoji.caractere}</span>
{:else}
<svg viewBox="0 0 100 60" width={tamanho} height={tamanho * 0.6}>
{#if passo?.olhos === 'feliz'}
<path d="M18 38 Q30 18 42 38 M58 38 Q70 18 82 38" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
{:else if passo?.olhos === 'triste'}
<path d="M18 26 Q30 44 42 26 M58 26 Q70 44 82 26" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
{:else}
{@const ry = passo?.olhos === 'surpreso' ? 20 : 16}
<ellipse cx="30" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
<ellipse cx="70" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
{/if}
</svg>
{#if simbolo}<span class="simbolo">{simbolo}</span>{/if}
{/if}
</div>
<style>
.miniatura {
position: relative;
width: var(--t);
height: var(--t);
display: grid;
place-items: center;
border-radius: var(--raio);
background: #05080c;
flex: none;
}
.emoji {
font-size: calc(var(--t) * 0.55);
line-height: 1;
}
.simbolo {
position: absolute;
right: 2px;
bottom: 0;
font-size: calc(var(--t) * 0.3);
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import type { Snippet } from 'svelte';
// <dialog> nativo: foco preso, Esc e fundo vêm de graça.
let { aberto = $bindable(false), titulo, children }: { aberto: boolean; titulo: string; children: Snippet } = $props();
let dialogo: HTMLDialogElement;
$effect(() => {
if (aberto && !dialogo.open) dialogo.showModal();
else if (!aberto && dialogo.open) dialogo.close();
});
</script>
<dialog
bind:this={dialogo}
onclose={() => (aberto = false)}
onclick={(e) => e.target === dialogo && (aberto = false)}
aria-label={titulo}
>
<div class="conteudo">
<header>
<h2>{titulo}</h2>
<button class="btn fantasma icone pequeno" aria-label="Fechar" onclick={() => (aberto = false)}><X size={18} /></button>
</header>
{#if aberto}{@render children()}{/if}
</div>
</dialog>
<style>
dialog {
width: min(480px, calc(100vw - 2 * var(--e-4)));
padding: 0;
border: 1px solid var(--borda-forte);
border-radius: var(--raio-lg);
background: var(--superficie);
color: var(--texto);
box-shadow: var(--sombra-flutuante);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.5);
}
.conteudo {
display: grid;
gap: var(--e-4);
padding: var(--e-5);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
h2 {
font-size: var(--t-lg);
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import { Search } from '@lucide/svelte';
import { itensDaPaleta, paleta, type ItemPaleta } from '../paleta.svelte';
let busca = $state('');
let ativo = $state(0);
let campo = $state<HTMLInputElement>();
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const itens = $derived.by(() => {
const termos = normal(busca).split(/\s+/).filter(Boolean);
return itensDaPaleta()
.filter((i) => termos.every((t) => normal(i.grupo + ' ' + i.titulo).includes(t)))
.slice(0, 50);
});
$effect(() => {
if (paleta.aberta) {
busca = '';
ativo = 0;
queueMicrotask(() => campo?.focus());
}
});
$effect(() => {
void busca;
ativo = 0;
});
function executar(i: ItemPaleta | undefined) {
if (!i) return;
paleta.fechar();
i.fazer();
}
function tecla(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
ativo = Math.min(itens.length - 1, ativo + 1);
e.preventDefault();
} else if (e.key === 'ArrowUp') {
ativo = Math.max(0, ativo - 1);
e.preventDefault();
} else if (e.key === 'Enter') {
executar(itens[ativo]);
e.preventDefault();
} else if (e.key === 'Escape') {
paleta.fechar();
e.stopPropagation();
}
}
</script>
{#if paleta.aberta}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="fundo" onclick={paleta.fechar}>
<div class="caixa" role="dialog" tabindex="-1" aria-modal="true" aria-label="Comandos" onclick={(e) => e.stopPropagation()}>
<div class="busca">
<Search size={18} />
<input
bind:this={campo}
bind:value={busca}
onkeydown={tecla}
placeholder="Gesto, rosto, tela ou ação…"
aria-label="Buscar comando"
role="combobox"
aria-expanded="true"
aria-controls="paleta-lista"
aria-activedescendant={itens[ativo] ? 'pal-' + itens[ativo].id : undefined}
/>
<kbd>Esc</kbd>
</div>
<ul id="paleta-lista" role="listbox">
{#each itens as item, i (item.id)}
{#if i === 0 || itens[i - 1].grupo !== item.grupo}
<li class="grupo" role="presentation">{item.grupo}</li>
{/if}
<li
id={'pal-' + item.id}
role="option"
aria-selected={i === ativo}
class:ativo={i === ativo}
onmousemove={() => (ativo = i)}
onclick={() => executar(item)}
>
<span>{item.titulo}</span>
{#if item.dica}<kbd>{item.dica}</kbd>{/if}
</li>
{:else}
<li class="vazio">Nada encontrado.</li>
{/each}
</ul>
</div>
</div>
{/if}
<style>
.fundo {
position: fixed;
inset: 0;
z-index: 95;
background: rgb(0 0 0 / 0.45);
display: grid;
justify-items: center;
align-items: start;
padding: 12vh var(--e-4) var(--e-4);
}
.caixa {
width: min(560px, 100%);
max-height: 70vh;
display: grid;
grid-template-rows: auto 1fr;
background: var(--vidro);
backdrop-filter: blur(18px);
border: 1px solid var(--borda-forte);
border-radius: var(--raio-lg);
box-shadow: var(--sombra-flutuante);
overflow: hidden;
}
.busca {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-3) var(--e-4);
border-bottom: 1px solid var(--borda);
color: var(--texto-3);
}
.busca input {
border: 0;
background: transparent;
min-height: 36px;
font-size: var(--t-md);
padding: 0;
}
.busca input:focus-visible {
outline: none;
}
ul {
list-style: none;
margin: 0;
padding: var(--e-2);
overflow: auto;
}
li[role='option'] {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
cursor: pointer;
font-size: var(--t-sm);
}
li.ativo {
background: var(--destaque-suave);
color: var(--destaque);
}
.grupo {
padding: var(--e-3) var(--e-3) var(--e-1);
font-size: var(--t-xs);
color: var(--texto-3);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.vazio {
padding: var(--e-4);
color: var(--texto-3);
font-size: var(--t-sm);
}
kbd {
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
border: 1px solid var(--borda);
border-radius: 4px;
padding: 1px 5px;
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
/**
* Prévia do rosto: o próprio carinha.html em modo ?painel=1 (sem polling).
* O painel repassa por postMessage o que chega no SSE, então a prévia mostra
* exatamente as mesmas ordens que o celular recebe.
*/
import { onDestroy } from 'svelte';
import { estado, ouvir } from '../estado.svelte';
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
let quadro: HTMLIFrameElement;
let pronto = $state(false);
function enviar(msg: unknown) {
if (pronto) quadro?.contentWindow?.postMessage(msg, location.origin);
}
function aoCarregar() {
pronto = true;
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
}
let ultimaConversa = estado.conversa?.ultima?.id;
const cancelar = [
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
ouvir('app_config', (c) => enviar({ tipo: 'config', config: c })),
ouvir('reacao', (r) => r?.receita && enviar({ tipo: 'previa-reacao', receita: r.receita })),
// O celular reage à resposta da conversa com acordar + gesto; a prévia imita.
ouvir('conversa', (c) => {
if (c.ultima && c.ultima.id !== ultimaConversa) {
ultimaConversa = c.ultima.id;
enviar({ tipo: 'ordem', ordem: 'acordar' });
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
}
}),
];
onDestroy(() => cancelar.forEach((c) => c()));
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita });
}
</script>
<iframe
bind:this={quadro}
src="/carinha.html?espelho=1&painel=1&auto=0&camera=0&audio=0"
title={titulo}
tabindex="-1"
onload={aoCarregar}
></iframe>
<style>
iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #000;
border-radius: inherit;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// Estado nunca só por cor: o selo sempre tem texto (e o ponto é decorativo).
let { tom = 'neutro', children }: { tom?: 'ok' | 'aviso' | 'perigo' | 'neutro' | 'destaque'; children: Snippet } =
$props();
</script>
<span class="selo {tom}"><span class="ponto" aria-hidden="true"></span>{@render children()}</span>
<style>
.selo {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--raio-pill);
font-size: var(--t-xs);
font-weight: 500;
background: var(--superficie-2);
color: var(--texto-2);
border: 1px solid var(--borda);
white-space: nowrap;
}
.ponto {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.ok {
color: var(--ok);
background: var(--ok-suave);
border-color: transparent;
}
.aviso {
color: var(--aviso);
background: var(--aviso-suave);
border-color: transparent;
}
.perigo {
color: var(--perigo);
background: var(--perigo-suave);
border-color: transparent;
}
.destaque {
color: var(--destaque);
background: var(--destaque-suave);
border-color: transparent;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
let { rotulo, valor, min, max, passo = 1, unidade = '', disabled = false, oninput, onchange }: {
rotulo: string;
valor: number;
min: number;
max: number;
passo?: number;
unidade?: string;
disabled?: boolean;
oninput?: (v: number) => void;
onchange?: (v: number) => void;
} = $props();
const id = 's' + Math.random().toString(36).slice(2, 9);
let local = $state<number | null>(null);
const mostrado = $derived(local ?? valor);
const casas = $derived(passo < 1 ? Math.min(2, String(passo).split('.')[1]?.length ?? 2) : 0);
</script>
<div class="slider">
<label for={id}>
<span>{rotulo}</span>
<output for={id} class="mono">{mostrado.toFixed(casas)}{unidade}</output>
</label>
<input
{id}
type="range"
{min}
{max}
step={passo}
value={mostrado}
{disabled}
style="--p: {((mostrado - min) / (max - min)) * 100}%"
oninput={(e) => {
local = Number(e.currentTarget.value);
oninput?.(local);
}}
onchange={(e) => {
const v = Number(e.currentTarget.value);
local = null;
onchange?.(v);
}}
/>
</div>
<style>
.slider {
display: grid;
gap: var(--e-1);
}
label {
display: flex;
justify-content: space-between;
font-size: var(--t-sm);
gap: var(--e-2);
}
output {
color: var(--texto-2);
}
input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 28px;
background: transparent;
cursor: pointer;
}
input::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
}
input::-moz-range-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
}
input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
margin-top: -7px;
border-radius: 50%;
background: var(--texto);
border: 3px solid var(--destaque);
}
input::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--texto);
border: 3px solid var(--destaque);
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
let { rotulo, ajuda = '', checked = false, disabled = false, onchange }: {
rotulo: string;
ajuda?: string;
checked?: boolean;
disabled?: boolean;
onchange: (v: boolean) => void;
} = $props();
const id = 't' + Math.random().toString(36).slice(2, 9);
</script>
<label class="toggle" for={id}>
<span class="texto">
<span>{rotulo}</span>
{#if ajuda}<small class="fraco">{ajuda}</small>{/if}
</span>
<input {id} type="checkbox" role="switch" {checked} {disabled} onchange={(e) => onchange(e.currentTarget.checked)} />
<span class="trilho" aria-hidden="true"></span>
</label>
<style>
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--e-3);
min-height: 44px;
cursor: pointer;
position: relative;
}
.texto {
display: grid;
font-size: var(--t-sm);
}
small {
font-size: var(--t-xs);
}
input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.trilho {
flex: none;
width: 42px;
height: 24px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
border: 1px solid var(--borda-forte);
position: relative;
transition: background var(--transicao);
}
.trilho::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--texto-2);
transition: transform var(--transicao), background var(--transicao);
}
input:checked + .trilho {
background: var(--destaque);
border-color: var(--destaque);
}
input:checked + .trilho::after {
transform: translateX(18px);
background: var(--destaque-texto);
}
input:focus-visible + .trilho {
outline: 2px solid var(--destaque);
outline-offset: 2px;
}
input:disabled + .trilho {
opacity: 0.5;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { KeyRound } from '@lucide/svelte';
import { gravarToken } from '../api';
import { conectar } from '../estado.svelte';
let token = $state('');
async function entrar(e: SubmitEvent) {
e.preventDefault();
gravarToken(token.trim());
await conectar();
}
</script>
<div class="fundo">
<form class="caixa" onsubmit={entrar}>
<KeyRound size={28} />
<h1>Token do Carinha</h1>
<p class="fraco pequeno-texto">
Este servidor exige um token para controlar o robô. Ele fica em <span class="mono">CARINHA_TOKEN</span> ou
<span class="mono">seguranca.json</span> na placa e é guardado só neste navegador.
</p>
<label class="campo">
<span>Token</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="password" bind:value={token} autocomplete="current-password" required minlength="8" autofocus />
</label>
<button class="btn primario" type="submit">Entrar</button>
</form>
</div>
<style>
.fundo {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: var(--e-4);
background: var(--fundo);
}
.caixa {
width: min(400px, 100%);
display: grid;
gap: var(--e-4);
padding: var(--e-6);
border-radius: var(--raio-lg);
background: var(--superficie);
border: 1px solid var(--borda);
color: var(--destaque);
}
h1,
p,
label {
color: var(--texto);
}
</style>
+161
View File
@@ -0,0 +1,161 @@
/**
* Estado do painel: uma foto inicial (/estado) + um único fluxo SSE (/eventos).
* Nenhuma tela faz polling; todas leem daqui.
*/
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
export interface ItemFeed {
id: number;
quando: number;
tipo: string;
texto: string;
}
class Estado {
conexao = $state<Conexao>('conectando');
versao = $state('');
autenticacao = $state(false);
estop = $state({ ativo: false, desde: null as number | null });
aparencia = $state({ rosto: 'carinha', cor: [92, 214, 255] as [number, number, number] });
gestos = $state<string[]>([]);
rostos = $state<string[]>([]);
app = $state<Telemetria | null>(null);
appRecebidoEm = $state(0);
appConfig = $state<AppConfig | null>(null);
motores = $state<Motores | null>(null);
conversa = $state<Conversa | null>(null);
ia = $state<IA | null>(null);
reacao = $state<Reacao | null>(null);
treinamento = $state({ ocupado: false, erro: '' });
api = $state<EstadoServidor['api'] | null>(null);
feed = $state<ItemFeed[]>([]);
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */
agora = $state(Date.now());
celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000);
}
export const estado = new Estado();
type Ouvinte = (dados: any) => void;
const ouvintes = new Map<string, Set<Ouvinte>>();
/** Assina um tipo de evento do servidor; devolve a função para cancelar. */
export function ouvir(tipo: string, fn: Ouvinte): () => void {
if (!ouvintes.has(tipo)) ouvintes.set(tipo, new Set());
ouvintes.get(tipo)!.add(fn);
return () => ouvintes.get(tipo)!.delete(fn);
}
let seqFeed = 0;
function noFeed(tipo: string, texto: string) {
estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
}
function aplicarFoto(f: EstadoServidor) {
estado.versao = f.versao;
estado.autenticacao = f.autenticacao;
estado.estop = f.estop;
estado.aparencia = f.aparencia;
estado.gestos = f.gestos;
estado.rostos = f.rostos;
if (f.app) {
estado.app = f.app;
estado.appRecebidoEm = Date.now();
}
if (f.app_config) estado.appConfig = f.app_config;
if (f.motores) estado.motores = f.motores;
if (f.conversa) estado.conversa = f.conversa;
if (f.ia) estado.ia = f.ia;
estado.reacao = f.reacao ?? null;
if (f.treinamento) estado.treinamento = f.treinamento;
if (f.api) estado.api = f.api;
}
const tratadores: Record<string, (d: any) => void> = {
expressao: (d: { ordem: string }) => {
if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem);
},
telemetria: (d: Telemetria) => {
estado.app = d;
estado.appRecebidoEm = Date.now();
},
app_config: (d: AppConfig) => (estado.appConfig = d),
aparencia: (d) => (estado.aparencia = d),
motores: (d: Motores) => (estado.motores = d),
estop: (d) => {
estado.estop = d;
noFeed('estop', d.ativo ? 'E-Stop acionado' : 'Motores rearmados');
},
conversa: (d: Conversa) => {
const nova = d.ultima && d.ultima.id !== estado.conversa?.ultima?.id;
estado.conversa = d;
if (nova) noFeed('conversa', 'Carinha: ' + d.ultima!.texto);
},
ia: (d: IA) => {
const nova = d.ultima && d.ultima.horario !== estado.ia?.ultima?.horario;
estado.ia = d;
if (nova) noFeed('ia', `IA local: ${d.ultima!.evento} → ${d.ultima!.gesto}`);
},
reacao: (d: Reacao | null) => {
estado.reacao = d;
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
},
treinamento: (d) => (estado.treinamento = d),
};
let fonte: EventSource | null = null;
let primeiraAbertura = true;
export async function carregarFoto() {
aplicarFoto(await pedir<EstadoServidor>('/estado'));
}
function abrirEventos() {
fonte?.close();
fonte = new EventSource(urlEventos());
fonte.addEventListener('pronto', async () => {
estado.conexao = 'aberta';
// Ao reconectar, ressincroniza: pode ter perdido eventos além do buffer do servidor.
if (!primeiraAbertura) carregarFoto().catch(() => {});
primeiraAbertura = false;
});
fonte.onerror = () => {
if (estado.conexao !== 'token') estado.conexao = 'caiu';
};
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento']) {
fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados);
ouvintes.get(tipo)?.forEach((fn) => fn(dados));
});
}
}
export async function conectar() {
estado.conexao = 'conectando';
try {
await carregarFoto();
} catch (e) {
if (e instanceof ErroApi && e.status === 401) {
estado.conexao = 'token';
return;
}
estado.conexao = 'caiu';
setTimeout(conectar, 3000);
return;
}
abrirEventos();
}
quandoNegado(() => {
estado.conexao = 'token';
fonte?.close();
fonte = null;
});
setInterval(() => (estado.agora = Date.now()), 1000);
+48
View File
@@ -0,0 +1,48 @@
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte';
import { estado } from './estado.svelte';
import { nav, ROTAS } from './rotas.svelte';
export interface ItemPaleta {
id: string;
grupo: string;
titulo: string;
dica?: string;
fazer: () => unknown;
}
type Fonte = () => ItemPaleta[];
const fontes = new Set<Fonte>();
export function registrarNaPaleta(fonte: Fonte): () => void {
fontes.add(fonte);
return () => fontes.delete(fonte);
}
class Paleta {
aberta = $state(false);
abrir = () => (this.aberta = true);
fechar = () => (this.aberta = false);
}
export const paleta = new Paleta();
export function itensDaPaleta(): ItemPaleta[] {
return [...fontes].flatMap((f) => f());
}
// Itens base: navegação, ações gerais, gestos, rostos e cores.
registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
: { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo },
{ id: 'recarregar', grupo: 'Ações', titulo: 'Recarregar tela do celular', fazer: recarregarCelular },
...gestosDisponiveis().map((g) => ({
id: 'gesto-' + g,
grupo: 'Gestos',
titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
fazer: () => gesto(g),
})),
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })),
...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
]);
+251
View File
@@ -0,0 +1,251 @@
/**
* Rastreamento de rosto/mãos no navegador do PC (MediaPipe num Worker servido pelo
* servidor em /rastreador-worker.js). Porta de painel-rastreamento.js + rastreamento-core.js.
*/
import type { AppConfig } from './tipos';
import type { AlvoRastreio, VideoAoVivo } from './video.svelte';
interface Ponto {
x: number;
y: number;
}
interface Face extends Ponto {
w: number;
h: number;
score: number;
pontos?: Ponto[];
}
interface Resultado {
faces: Face[];
hands: Ponto[][];
ms: number;
timestamp: number;
}
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
const limitar = (v: number) => Math.max(0, Math.min(1, v));
const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12],
[9, 13], [13, 14], [14, 15], [15, 16], [13, 17], [0, 17], [17, 18], [18, 19], [19, 20]];
export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null): AlvoRastreio | null {
let candidatos: AlvoRastreio[] = maos
.filter((h) => h.length === 21 && h.every(valido))
.map((h) => {
const palma = [0, 5, 9, 13, 17].map((i) => h[i]);
return { tipo: 'mao', x: palma.reduce((s, p) => s + p.x, 0) / 5, y: palma.reduce((s, p) => s + p.y, 0) / 5 };
});
if (!candidatos.length)
candidatos = faces
.filter((f) => valido(f) && f.w > 0 && f.h > 0)
.sort((a, b) => b.w * b.h - a.w * a.h)
.map((f) => ({ tipo: 'rosto', x: f.x + f.w / 2, y: f.y + f.h / 2 }));
if (!candidatos.length) return null;
if (anterior && anterior.tipo === candidatos[0].tipo)
candidatos.sort((a, b) => Math.hypot(a.x - anterior.x, a.y - anterior.y) - Math.hypot(b.x - anterior.x, b.y - anterior.y));
const t = candidatos[0];
return { ...t, x: limitar(t.x), y: limitar(t.y) };
}
function encaixe(w: number, h: number, vw: number, vh: number) {
const escala = Math.min(w / vw, h / vh);
return { x: Math.max(0, (w - vw * escala) / 2), y: Math.max(0, (h - vh * escala) / 2), w: vw * escala, h: vh * escala };
}
export class Rastreamento {
texto = $state('Rastreamento desligado.');
private worker: Worker | null = null;
private pronto = false;
private pendente = false;
private geracao = 0;
private ativo = false;
private config: AppConfig = {};
private resultado: Resultado | null = null;
private alvo: AlvoRastreio | null = null;
private alvoEm = 0;
private em = 0;
private intervalo = 50;
private proximo = 0;
private ultimoVideo = -1;
private tentarEm = 0;
private amostraAnterior = 0;
private rascunho = document.createElement('canvas');
private relogio: number;
private ctx: CanvasRenderingContext2D;
constructor(
private video: HTMLVideoElement,
private tela: HTMLCanvasElement,
private saida: VideoAoVivo,
) {
this.ctx = tela.getContext('2d')!;
this.relogio = window.setInterval(() => this.passo(), 20);
addEventListener('resize', this.desenhar);
}
private limpar() {
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
}
private parar(texto: string) {
if (this.ativo) this.saida.rastrear(null);
this.ativo = false;
this.geracao++;
this.worker?.terminate();
this.worker = null;
this.pendente = this.pronto = false;
this.resultado = this.alvo = null;
this.ultimoVideo = -1;
this.amostraAnterior = 0;
this.limpar();
this.texto = texto;
}
private desenhar = () => {
const caixa = this.tela.parentElement!.getBoundingClientRect();
if (this.tela.width !== Math.round(caixa.width) || this.tela.height !== Math.round(caixa.height)) {
this.tela.width = Math.round(caixa.width);
this.tela.height = Math.round(caixa.height);
}
this.limpar();
this.tela.hidden = !this.config.mostrarRastreamento;
const r = this.resultado;
if (this.tela.hidden || !r || performance.now() - this.em > 350 || !this.video.videoWidth) return;
const f = encaixe(this.tela.width, this.tela.height, this.video.videoWidth, this.video.videoHeight);
const pt = (p: Ponto) => ({ x: f.x + p.x * f.w, y: f.y + p.y * f.h });
const c = this.ctx;
c.lineWidth = 2;
c.font = '13px system-ui';
const bola = (p: Ponto, raio = 3) => {
c.beginPath();
c.arc(p.x, p.y, raio, 0, Math.PI * 2);
c.fill();
};
for (const rosto of r.faces) {
const p = pt(rosto);
c.strokeStyle = c.fillStyle = '#5cd6ff';
c.strokeRect(p.x, p.y, rosto.w * f.w, rosto.h * f.h);
c.fillText('Rosto ' + Math.round(rosto.score * 100) + '%', p.x, Math.max(15, p.y - 6));
for (const q of rosto.pontos || []) bola(pt(q));
}
for (const mao of r.hands) {
c.strokeStyle = c.fillStyle = '#4ade80';
for (const [a, b] of LIGACOES) {
const p = pt(mao[a]);
const q = pt(mao[b]);
c.beginPath();
c.moveTo(p.x, p.y);
c.lineTo(q.x, q.y);
c.stroke();
}
for (const q of mao) bola(pt(q));
}
if (this.alvo) {
const p = pt(this.alvo);
c.strokeStyle = '#fbbf24';
c.beginPath();
c.arc(p.x, p.y, 12, 0, Math.PI * 2);
c.moveTo(p.x - 18, p.y);
c.lineTo(p.x + 18, p.y);
c.moveTo(p.x, p.y - 18);
c.lineTo(p.x, p.y + 18);
c.stroke();
}
};
private iniciar() {
this.ativo = true;
const geracao = ++this.geracao;
this.worker = new Worker('/rastreador-worker.js');
this.texto = 'Carregando rastreadores locais…';
this.worker.onerror = (e) => {
this.tentarEm = Date.now() + 10000;
this.parar('Falha no rastreador: ' + e.message);
};
this.worker.onmessage = ({ data }) => {
if (geracao !== this.geracao) return;
this.pendente = false;
if (data.tipo === 'erro') {
this.tentarEm = Date.now() + 10000;
this.parar('Rastreamento indisponível: ' + data.erro);
return;
}
this.pronto = true;
if (data.tipo === 'pronto') {
this.texto = 'Rastreadores prontos. Aguardando imagem…';
return;
}
const r = data as Resultado;
this.resultado = r;
this.em = r.timestamp;
const anterior = this.alvo;
const alvo = escolherAlvo(r.faces, r.hands, anterior);
if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008;
this.alvo = alvo;
this.alvoEm = r.timestamp;
this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
const hz = this.amostraAnterior ? Math.round(1000 / (r.timestamp - this.amostraAnterior)) : 0;
this.amostraAnterior = r.timestamp;
this.texto = `${r.faces.length} rosto(s) · ${r.hands.length} mão(s) · ${hz} análises/s · ${Math.round(r.ms)} ms${
alvo ? ' · alvo: ' + (alvo.tipo === 'mao' ? 'mão' : 'rosto') : ' · procurando…'
}`;
if (this.config.seguirRastreamento) this.saida.rastrear(alvo);
this.desenhar();
};
this.pendente = true;
this.worker.postMessage({ config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } });
}
private async passo() {
const agora = performance.now();
if (this.alvo && agora - this.alvoEm > 350) {
this.alvo = null;
if (this.config.seguirRastreamento) this.saida.rastrear(null);
}
if (this.resultado && agora - this.em > 350) {
this.resultado = null;
this.limpar();
}
const v = this.video;
if (!this.ativo || !this.pronto || this.pendente || agora < this.proximo || v.readyState < 2 || v.currentTime === this.ultimoVideo)
return;
this.pendente = true;
this.proximo = agora + this.intervalo;
this.ultimoVideo = v.currentTime;
const geracao = this.geracao;
try {
const escala = Math.min(1, 640 / v.videoWidth);
this.rascunho.width = Math.round(v.videoWidth * escala);
this.rascunho.height = Math.round(v.videoHeight * escala);
this.rascunho.getContext('2d')!.drawImage(v, 0, 0, this.rascunho.width, this.rascunho.height);
const bitmap = await createImageBitmap(this.rascunho);
if (geracao !== this.geracao) return bitmap.close();
this.worker!.postMessage(
{ bitmap, timestamp: agora, config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } },
[bitmap],
);
} catch (e) {
if (geracao === this.geracao) this.parar('Falha ao analisar vídeo: ' + (e as Error).message);
}
}
atualizar(config: AppConfig | null, permitido: boolean) {
if (!config) return;
const mudou = this.config.rastrearRosto !== config.rastrearRosto || this.config.rastrearMaos !== config.rastrearMaos;
if (this.config.seguirRastreamento && !config.seguirRastreamento) this.saida.rastrear(null);
this.config = config;
if (!permitido || (!config.rastrearRosto && !config.rastrearMaos)) {
this.parar(!permitido ? 'Ligue a transmissão para rastrear no PC.' : 'Rastreamento desligado.');
return;
}
if (mudou && this.ativo) this.parar('Atualizando rastreadores…');
if (!this.ativo && Date.now() >= this.tentarEm) this.iniciar();
this.desenhar();
}
destruir() {
clearInterval(this.relogio);
removeEventListener('resize', this.desenhar);
this.parar('Painel fechado.');
}
}
+26
View File
@@ -0,0 +1,26 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
];
function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo';
}
class Navegacao {
atual = $state<Rota>(lerHash());
ir(rota: Rota) {
location.hash = '/' + rota;
}
}
export const nav = new Navegacao();
addEventListener('hashchange', () => (nav.atual = lerHash()));
+128
View File
@@ -0,0 +1,128 @@
/* Design system do painel Carinha: tokens + poucos utilitários. Escuro por padrão, claro opcional. */
:root {
color-scheme: dark;
--fundo: #0b1017;
--fundo-2: #0f1620;
--superficie: #141c27;
--superficie-2: #1a2431;
--superficie-3: #223041;
--borda: #263447;
--borda-forte: #34475f;
--texto: #e8eef6;
--texto-2: #a9b8c9;
--texto-3: #8396ab; /* AA sobre --superficie */
--destaque: #5cd6ff;
--destaque-forte: #2fbff0;
--destaque-texto: #04202b;
--destaque-suave: rgb(92 214 255 / 0.12);
--ok: #4ade80;
--ok-suave: rgb(74 222 128 / 0.12);
--aviso: #fbbf24;
--aviso-suave: rgb(251 191 36 / 0.12);
--perigo: #f87171;
--perigo-forte: #ef4444;
--perigo-suave: rgb(248 113 113 / 0.14);
--vidro: rgb(15 22 32 / 0.72);
--fonte: 'Outfit Variable', 'Segoe UI', system-ui, sans-serif;
--fonte-mono: 'JetBrains Mono Variable', ui-monospace, Consolas, monospace;
--t-xs: 0.75rem; --t-sm: 0.875rem; --t-md: 1rem; --t-lg: 1.25rem; --t-xl: 1.6rem;
--e-1: 4px; --e-2: 8px; --e-3: 12px; --e-4: 16px; --e-5: 24px; --e-6: 32px;
--raio-sm: 8px; --raio: 12px; --raio-lg: 18px; --raio-pill: 999px;
--sombra: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px rgb(0 0 0 / 0.25);
--sombra-flutuante: 0 12px 40px rgb(0 0 0 / 0.45);
--transicao: 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
--nav-largura: 232px;
--topo-altura: 60px;
}
:root[data-theme='light'] {
color-scheme: light;
--fundo: #f4f6f9;
--fundo-2: #eef2f6;
--superficie: #ffffff;
--superficie-2: #f3f6fa;
--superficie-3: #e7edf4;
--borda: #dbe3ec;
--borda-forte: #c3cfdc;
--texto: #0f1722;
--texto-2: #3d4d60;
--texto-3: #5a6b7f;
--destaque: #0284c7;
--destaque-forte: #0369a1;
--destaque-texto: #ffffff;
--destaque-suave: rgb(2 132 199 / 0.1);
--ok: #15803d; --ok-suave: rgb(21 128 61 / 0.1);
--aviso: #b45309; --aviso-suave: rgb(180 83 9 / 0.1);
--perigo: #dc2626; --perigo-forte: #b91c1c; --perigo-suave: rgb(220 38 38 / 0.1);
--vidro: rgb(255 255 255 / 0.78);
--sombra: 0 1px 2px rgb(15 23 34 / 0.06), 0 6px 20px rgb(15 23 34 / 0.08);
--sombra-flutuante: 0 12px 40px rgb(15 23 34 / 0.18);
}
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--fundo);
color: var(--texto);
font: 400 var(--t-md)/1.5 var(--fonte);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 { margin: 0; line-height: 1.2; font-weight: 600; }
h1 { font-size: var(--t-xl); }
h2 { font-size: var(--t-lg); }
h3 { font-size: var(--t-md); }
p { margin: 0; }
a { color: var(--destaque); }
img, video, canvas, iframe { max-width: 100%; }
[hidden] { display: none !important; }
:focus-visible { outline: 2px solid var(--destaque); outline-offset: 2px; }
/* ---------- botões ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: var(--e-2);
min-height: 40px; padding: 0 var(--e-4);
border: 1px solid var(--borda-forte); border-radius: var(--raio-sm);
background: var(--superficie-2); color: var(--texto);
font: 500 var(--t-sm) var(--fonte); cursor: pointer; white-space: nowrap;
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
touch-action: manipulation; user-select: none;
}
.btn:hover:not(:disabled) { background: var(--superficie-3); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn.primario { background: var(--destaque); border-color: var(--destaque); color: var(--destaque-texto); }
.btn.primario:hover:not(:disabled) { background: var(--destaque-forte); }
.btn.perigo { background: var(--perigo-forte); border-color: var(--perigo-forte); color: #fff; }
.btn.perigo:hover:not(:disabled) { background: var(--perigo); }
.btn.fantasma { background: transparent; border-color: transparent; }
.btn.fantasma:hover:not(:disabled) { background: var(--superficie-2); }
.btn.icone { padding: 0; width: 40px; }
.btn.pequeno { min-height: 32px; padding: 0 var(--e-3); font-size: var(--t-xs); }
.btn.pequeno.icone { width: 32px; padding: 0; }
/* ---------- formulários ---------- */
.campo { display: grid; gap: var(--e-1); }
.campo > span, .rotulo { font-size: var(--t-sm); color: var(--texto-2); }
input[type='text'], input[type='password'], input[type='search'], input[type='number'], select, textarea {
width: 100%; min-height: 40px; padding: var(--e-2) var(--e-3);
border: 1px solid var(--borda-forte); border-radius: var(--raio-sm);
background: var(--fundo-2); color: var(--texto); font: inherit; font-size: var(--t-sm);
}
textarea { resize: vertical; min-height: 80px; }
input::placeholder, textarea::placeholder { color: var(--texto-3); }
/* ---------- utilitários ---------- */
.grade { display: grid; gap: var(--e-4); }
.linha { display: flex; gap: var(--e-2); align-items: center; flex-wrap: wrap; }
.entre { justify-content: space-between; }
.mono { font-family: var(--fonte-mono); font-size: 0.92em; }
.fraco { color: var(--texto-3); }
.pequeno-texto { font-size: var(--t-sm); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0ms !important; animation-duration: 0ms !important; }
}
+81
View File
@@ -0,0 +1,81 @@
/** Formato do que o servidor manda em /estado e nos eventos SSE. */
export type AppConfig = Record<string, boolean | number | string>;
export interface Telemetria {
online: boolean;
idade: number | null;
celular_ip: string | null;
servidor_ip: string;
recarga: { id: string | null; confirmada: boolean };
estado: {
rosto?: string;
dormindo?: boolean;
microfone?: number;
microfone_ativo?: boolean;
movimento?: number;
camera_ativa?: boolean;
bateria?: { nivel?: number; carregando?: boolean } | number | null;
brilho?: number;
video?: { qualidade?: string } | null;
[k: string]: unknown;
};
preview: boolean;
}
export interface Motores {
disponivel: boolean;
erro: string;
ativos: number[];
dono: 'braco' | 'diagnostico' | null;
travado: boolean;
}
export interface Mensagem {
role: 'user' | 'assistant';
content: string;
}
export interface Conversa {
provedor: 'local' | 'gemini';
modelo: string;
gemini_configurado: boolean;
ocupado: boolean;
ultima: { id: number; texto: string; gesto: string; horario: number; segundos: number; fonte?: string } | null;
erro: string;
historico: Mensagem[];
lembrancas: { id: number; texto: string }[];
}
export interface IA {
ativo: boolean;
ocupado: boolean;
modelo: string;
executor: string;
ultima: { evento: string; gesto: string; segundos: number; horario: number } | null;
erro: string;
}
export interface Reacao {
id: number;
horario: number;
receita: any;
}
export interface EstadoServidor {
versao: string;
seq: number;
autenticacao: boolean;
estop: { ativo: boolean; desde: number | null };
aparencia: { rosto: string; cor: [number, number, number] };
gestos: string[];
rostos: string[];
app?: Telemetria;
app_config?: AppConfig;
motores?: Motores;
conversa?: Conversa;
ia?: IA;
reacao?: Reacao | null;
treinamento?: { ocupado: boolean; erro: string };
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Biblioteca de reações, rotinas e diário (/treinamento/*). Sem polling: recarrega ao abrir a
* tela, depois de cada ação e quando o servidor avisa (SSE 'treinamento') que terminou de criar.
*/
import { pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export interface ReacaoBiblioteca {
id: number;
horario: number;
receita: any;
feedback: -1 | 0 | 1;
ativa: boolean;
}
export interface StatusTreinamento {
ocupado: boolean;
erro: string;
automatico: boolean;
rotina: { id: number; texto: string }[];
diario: { id: number; horario: number; papel: string; texto: string }[];
resumos: { id: number; horario: number; texto: string }[];
emojis: string[];
desfazer: [string] | null;
reacoes: ReacaoBiblioteca[];
prompt_orientacao: string;
ultimo_prompt_figurinha: string;
}
class Treinamento {
status = $state<StatusTreinamento | null>(null);
carregando = $state(false);
async carregar() {
this.carregando = true;
try {
this.status = await pedir<StatusTreinamento>('/treinamento/status');
} catch (e) {
avisos.erro(e);
} finally {
this.carregando = false;
}
}
/** Executa uma ação e recarrega; devolve true se deu certo (erro vira toast). */
async acao(rota: string, dados: object = {}, sucesso?: string) {
try {
await postar('/treinamento/' + rota, dados);
if (sucesso) avisos.ok(sucesso);
await this.carregar();
return true;
} catch (e) {
avisos.erro(e);
return false;
}
}
}
export const treinamento = new Treinamento();
let estavaOcupado = false;
ouvir('treinamento', (d: { ocupado: boolean; erro: string }) => {
if (treinamento.status) treinamento.status = { ...treinamento.status, ocupado: d.ocupado, erro: d.erro };
if (estavaOcupado && !d.ocupado) {
if (d.erro) avisos.mostrar('Gemini: ' + d.erro, 'erro');
else avisos.ok('Novas reações prontas.');
treinamento.carregar();
}
estavaOcupado = d.ocupado;
});
export const SIMBOLO_EMOJI: Record<string, string> = { ideia: '💡', cafe: '☕', musica: '🎵', coracao: '❤️', estrela: '⭐' };
+236
View File
@@ -0,0 +1,236 @@
/**
* Vídeo ao vivo do celular por WebRTC P2P. A sinalização passa pelo servidor (/rtc/*);
* o vídeo vai direto do celular para este navegador. Porta de painel-video.js.
* Só a tela Ao vivo cria uma instância: assim não há disputa pela única sessão.
*/
import { lerToken, pedir } from './api';
import type { AppConfig, Telemetria } from './tipos';
export interface AlvoRastreio {
tipo: 'mao' | 'rosto';
x: number;
y: number;
atividade?: boolean;
}
export class VideoAoVivo {
mensagem = $state('Transmissão desligada.');
exibindo = $state(false);
qualidade = $state('');
private pc: RTCPeerConnection | null = null;
private id: string | null = null;
private desde = 0;
private ice: RTCIceCandidateInit[] = [];
private querido = false;
private abrindo = false;
private consultando = false;
private geracao = 0;
private tentarEm = 0;
private ultimoStats: any = null;
private outroPainel = false;
private canal: RTCDataChannel | null = null;
private seqRastreio = 0;
private qualidadeEfetiva = '';
private config: AppConfig = {};
private relogios: number[] = [];
constructor(private video: HTMLVideoElement) {
video.onloadeddata = video.onplaying = () => this.mostrar();
this.relogios.push(
window.setInterval(() => {
this.iniciar();
this.consultar();
}, 400),
window.setInterval(() => this.estatisticas(), 1000),
);
addEventListener('pagehide', this.aoSair);
}
private mostrar() {
this.exibindo = true;
}
private texto(t: string) {
this.mensagem = t;
this.exibindo = false;
}
private parar(fechar = true) {
this.geracao++;
const antiga = this.id;
this.id = null;
this.pc?.close();
this.pc = null;
this.canal = null;
this.ice = [];
this.desde = 0;
this.ultimoStats = null;
this.video.pause();
this.video.srcObject = null;
this.exibindo = false;
if (antiga && fechar) pedir('/rtc/session', { acao: 'fechar', id: antiga }).catch(() => {});
}
private enviar(mensagem: unknown, sessao?: string) {
return pedir('/rtc/send', { id: sessao || this.id, de: 'pc', mensagem }, 3500);
}
private async iniciar() {
if (!this.querido || this.pc || this.abrindo || this.outroPainel || Date.now() < this.tentarEm) return;
this.abrindo = true;
const geracao = this.geracao;
this.texto('Conectando vídeo ao vivo… O rosto continua no celular.');
try {
const sessao = await pedir<{ id: string }>('/rtc/session', { acao: 'abrir' }, 3500);
if (geracao !== this.geracao || !this.querido) {
pedir('/rtc/session', { acao: 'fechar', id: sessao.id }).catch(() => {});
return;
}
this.id = sessao.id;
const id = sessao.id;
const pc = new RTCPeerConnection({ iceServers: [] });
this.pc = pc;
this.canal = pc.createDataChannel('rastreamento', { ordered: false, maxRetransmits: 0 });
pc.addTransceiver('video', { direction: 'recvonly' });
pc.onicecandidate = (e) => {
if (this.pc === pc && e.candidate) this.enviar({ tipo: 'candidate', candidate: e.candidate.toJSON() }, id).catch(() => {});
};
pc.ontrack = (e) => {
if (this.pc !== pc) return;
this.video.srcObject = e.streams[0] || new MediaStream([e.track]);
this.video.play().catch((err) => this.texto('Clique no vídeo para permitir a reprodução: ' + err.message));
};
pc.onconnectionstatechange = () => {
if (this.pc === pc && pc.connectionState === 'failed') {
this.parar();
this.tentarEm = Date.now() + 2000;
this.texto('Reconectando o vídeo…');
}
};
await pc.setLocalDescription(await pc.createOffer());
if (this.pc !== pc) return;
await this.enviar({ tipo: 'offer', sdp: pc.localDescription!.sdp }, id);
} catch (e) {
this.parar();
this.tentarEm = Date.now() + 2500;
this.texto('Falha ao iniciar vídeo: ' + (e as Error).message);
} finally {
this.abrindo = false;
}
}
private async consultar() {
if (!this.id || !this.pc || this.consultando) return;
this.consultando = true;
const pc = this.pc;
const sid = this.id;
try {
const pacote = await pedir<{ id: string | null; mensagens: { seq: number; mensagem: any }[] }>(
`/rtc/poll?lado=pc&desde=${this.desde}&id=${sid}`,
undefined,
3500,
);
if (this.pc !== pc) return;
if (pacote.id !== sid) {
this.outroPainel = !!pacote.id;
this.parar(false);
this.texto(this.outroPainel ? 'Vídeo aberto em outro painel. Recarregue para assumir.' : 'Aguardando reconexão do vídeo…');
return;
}
for (const { seq, mensagem: m } of pacote.mensagens) {
if (m.tipo === 'answer') {
await pc.setRemoteDescription({ type: 'answer', sdp: m.sdp });
for (const c of this.ice) await pc.addIceCandidate(c);
this.ice = [];
} else if (m.tipo === 'candidate') {
if (pc.remoteDescription) await pc.addIceCandidate(m.candidate);
else this.ice.push(m.candidate);
} else if (m.tipo === 'erro') {
this.parar();
this.tentarEm = Date.now() + 3500;
this.texto('Câmera no celular: ' + m.erro);
return;
}
this.desde = Math.max(this.desde, seq);
}
} catch {
if (this.pc === pc) {
this.parar();
this.tentarEm = Date.now() + 2500;
this.texto('Sinalização interrompida. Reconectando…');
}
} finally {
this.consultando = false;
}
}
private async estatisticas() {
const pc = this.pc;
if (!pc || pc.connectionState !== 'connected') return;
try {
const relatorio = await pc.getStats();
relatorio.forEach((s: any) => {
if (s.type !== 'inbound-rtp' || (s.kind !== 'video' && s.mediaType !== 'video')) return;
const u = this.ultimoStats;
const fps = u ? ((s.framesDecoded - u.framesDecoded) * 1000) / (s.timestamp - u.timestamp) : s.framesPerSecond || 0;
const kbps = u ? ((s.bytesReceived - u.bytesReceived) * 8) / (s.timestamp - u.timestamp) : 0;
this.ultimoStats = s;
const auto =
this.qualidadeEfetiva === 'auto' || (!this.qualidadeEfetiva && this.config.videoResolucao === 'auto' && !this.config.videoFPS);
this.qualidade = `${auto ? 'Automática' : 'Manual'} · ${s.frameWidth || this.video.videoWidth}×${s.frameHeight || this.video.videoHeight} · ${Math.round(fps)} fps · ${Math.round(kbps)} kb/s`;
if (fps > 0) this.mostrar();
});
} catch {
/* conexão em transição */
}
}
rastrear(alvo: AlvoRastreio | null) {
if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096)
this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo }));
}
atualizar(status: Telemetria | null, config: AppConfig | null, online: boolean) {
if (!config) return;
this.config = config;
this.qualidadeEfetiva = (status?.estado?.video as any)?.qualidade || '';
const dormindo = !!status?.estado?.dormindo;
const liberada = config.sensorCamera !== false && (!dormindo || config.cameraSono === true);
const quer = !!config.preview && online && liberada;
if (!quer) {
if (this.querido || this.pc) this.parar();
this.querido = false;
this.texto(
!liberada
? 'Câmera desativada pelos ajustes de sensores.'
: config.preview
? 'Celular sem conexão. Abra o app Carinha.'
: 'Transmissão desligada. O rosto e os sensores continuam funcionando.',
);
} else {
this.querido = true;
this.iniciar();
}
}
private aoSair = () => {
if (this.id) {
const t = lerToken();
navigator.sendBeacon(
'/rtc/session' + (t ? '?t=' + encodeURIComponent(t) : ''),
new Blob([JSON.stringify({ acao: 'fechar', id: this.id })], { type: 'application/json' }),
);
}
this.querido = false;
this.parar(false);
};
destruir() {
this.relogios.forEach(clearInterval);
removeEventListener('pagehide', this.aoSair);
this.querido = false;
this.outroPainel = false;
this.parar();
}
}
+7
View File
@@ -0,0 +1,7 @@
import '@fontsource-variable/outfit';
import '@fontsource-variable/jetbrains-mono';
import './lib/tema.css';
import { mount } from 'svelte';
import App from './App.svelte';
mount(App, { target: document.getElementById('app')! });
+341
View File
@@ -0,0 +1,341 @@
<script lang="ts">
import { Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte';
import { gravarToken, lerToken, postar } from '../lib/api';
import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte';
import { avisos, tentar } from '../lib/avisos.svelte';
import { recarregarCelular } from '../lib/comandos.svelte';
import Card from '../lib/componentes/Card.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Slider from '../lib/componentes/Slider.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { conectar, estado } from '../lib/estado.svelte';
// ---------- busca nos ajustes do celular
let busca = $state('');
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const grupos = $derived(
GRUPOS.map((g) => ({
...g,
campos: g.campos.filter((c) => !busca || normal(c.rotulo + ' ' + (c.ajuda ?? '')).includes(normal(busca))),
})).filter((g) => g.campos.length),
);
const valor = (c: Campo) => estado.appConfig?.[c.chave];
let confirmarPadrao = $state(false);
// ---------- IA
let modelo = $state('');
let chave = $state('');
let testando = $state(false);
let resultadoTeste = $state<{ ok: boolean; mensagem: string; segundos?: number } | null>(null);
$effect(() => {
if (estado.api && !modelo) modelo = estado.api.modelo;
});
async function trocarProvedor(provedor: string) {
await tentar(async () => {
estado.conversa = await postar('/conversa/config', { provedor });
}, provedor === 'gemini' ? 'Conversa usando Gemini.' : 'Conversa usando Qwen local.');
}
async function salvarGemini(e: SubmitEvent) {
e.preventDefault();
const dados: Record<string, string> = { modelo: modelo.trim() };
if (chave.trim()) dados.chave = chave.trim();
const r = await tentar(() => postar('/api/configuracao/salvar', dados), 'Configuração do Gemini salva.');
if (r) {
estado.api = r;
chave = '';
resultadoTeste = null;
}
}
async function testarGemini() {
testando = true;
resultadoTeste = (await tentar(() => postar('/api/configuracao/testar'))) ?? null;
testando = false;
}
// ---------- segurança
let tokenLocal = $state(!!lerToken());
function esquecerToken() {
gravarToken('');
tokenLocal = false;
avisos.ok('Token removido deste navegador.');
if (estado.autenticacao) conectar();
}
</script>
<div class="ajustes">
<Card titulo="Celular" descricao="Mudanças valem na hora no aparelho. Nada trava enquanto salva.">
{#snippet acoes()}
<label class="busca">
<Search size={16} />
<input type="search" bind:value={busca} placeholder="Buscar ajuste" aria-label="Buscar ajuste" />
</label>
{#if confirmarPadrao}
<span class="pequeno-texto">Restaurar todos?</span>
<button
class="btn pequeno perigo"
onclick={async () => {
confirmarPadrao = false;
await tentar(restaurarPadrao);
}}>Sim, restaurar</button
>
<button class="btn pequeno" onclick={() => (confirmarPadrao = false)}>Cancelar</button>
{:else}
<button class="btn pequeno" onclick={() => (confirmarPadrao = true)} disabled={!estado.appConfig}>
<RotateCcw size={14} /> Restaurar padrão
</button>
{/if}
{/snippet}
{#if !estado.appConfig}
<p class="fraco">Carregando ajustes…</p>
{:else}
<div class="grupos">
{#each grupos as g (g.titulo)}
<fieldset>
<legend>
<strong>{g.titulo}</strong>
<span class="fraco pequeno-texto">{g.descricao}</span>
</legend>
{#each g.campos as c (c.chave)}
{#if c.tipo === 'toggle'}
<Toggle rotulo={c.rotulo} ajuda={c.ajuda} checked={!!valor(c)} onchange={(v) => salvarApp({ [c.chave]: v })} />
{:else if c.tipo === 'slider'}
<Slider
rotulo={c.rotulo}
valor={Number(valor(c) ?? 0)}
min={c.min!}
max={c.max!}
passo={c.passo}
unidade={c.unidade}
onchange={(v) => salvarApp({ [c.chave]: v })}
/>
{:else}
<label class="campo escolha">
<span>{c.rotulo}</span>
<select
value={String(valor(c))}
onchange={(e) => {
const v = e.currentTarget.value;
salvarApp({ [c.chave]: typeof c.opcoes![0][0] === 'number' ? Number(v) : v });
}}
>
{#each c.opcoes! as [v, t] (v)}<option value={String(v)}>{t}</option>{/each}
</select>
</label>
{/if}
{/each}
</fieldset>
{:else}
<p class="fraco">Nenhum ajuste com “{busca}”.</p>
{/each}
</div>
{/if}
</Card>
<div class="coluna">
<Card titulo="Inteligência artificial">
<div class="campo">
<span>Motor da conversa</span>
<div class="segmentado" role="radiogroup" aria-label="Motor da conversa">
<button role="radio" aria-checked={estado.conversa?.provedor === 'local'} onclick={() => trocarProvedor('local')}>
Qwen local
</button>
<button
role="radio"
aria-checked={estado.conversa?.provedor === 'gemini'}
disabled={!estado.api?.configurada}
title={estado.api?.configurada ? '' : 'Configure a chave do Gemini abaixo'}
onclick={() => trocarProvedor('gemini')}
>
Gemini
</button>
</div>
<small class="fraco">Local roda no celular/placa, sem custo. Gemini responde melhor e cria reações.</small>
</div>
<form class="gemini" onsubmit={salvarGemini}>
<div class="linha entre">
<strong class="pequeno-texto">Gemini</strong>
<Selo tom={estado.api?.configurada ? 'ok' : 'aviso'}>{estado.api?.configurada ? 'Chave configurada' : 'Sem chave'}</Selo>
</div>
<label class="campo">
<span>Modelo</span>
<input type="text" bind:value={modelo} list="modelos-gemini" required pattern="[a-zA-Z0-9._\-]{'{'}3,100{'}'}" />
<datalist id="modelos-gemini">
<option value="gemini-flash-lite-latest"></option>
<option value="gemini-flash-latest"></option>
<option value="gemini-2.5-flash-lite"></option>
<option value="gemini-2.5-flash"></option>
<option value="gemini-2.5-pro"></option>
</datalist>
</label>
<label class="campo">
<span>Chave de API {estado.api?.configurada ? '(deixe vazio para manter)' : ''}</span>
<input type="password" bind:value={chave} autocomplete="off" placeholder="AIza…" />
</label>
<div class="linha">
<button class="btn primario" type="submit"><KeyRound size={16} /> Salvar</button>
<button class="btn" type="button" onclick={testarGemini} disabled={testando || !estado.api?.configurada}>
{testando ? 'Testando…' : 'Testar conexão'}
</button>
</div>
{#if resultadoTeste}
<p class="pequeno-texto" class:ok={resultadoTeste.ok} class:erro={!resultadoTeste.ok}>
{resultadoTeste.mensagem}{resultadoTeste.segundos ? ` (${resultadoTeste.segundos}s)` : ''}
</p>
{/if}
</form>
</Card>
<Card titulo="Segurança">
<div class="linha">
{#if estado.autenticacao}
<Selo tom="ok"><ShieldCheck size={12} /> Servidor exige token</Selo>
{:else}
<Selo tom="aviso"><ShieldOff size={12} /> Servidor aberto na rede</Selo>
{/if}
</div>
<p class="fraco pequeno-texto">
{#if estado.autenticacao}
Só quem tem o token controla o robô. Ele fica guardado apenas neste navegador.
{:else}
Qualquer aparelho da rede local pode controlar o robô. Defina <span class="mono">CARINHA_TOKEN</span> na placa
(veja docs/SEGURANCA.md) depois de atualizar o app do celular.
{/if}
</p>
{#if tokenLocal}
<button class="btn pequeno" onclick={esquecerToken}>Esquecer token neste navegador</button>
{/if}
</Card>
<Card titulo="Sistema">
<dl class="info">
<div><dt>Versão do servidor</dt><dd class="mono">{estado.versao || '—'}</dd></div>
<div><dt>Celular</dt><dd class="mono">{estado.app?.celular_ip ?? 'offline'}</dd></div>
<div><dt>Servidor</dt><dd class="mono">{location.host}</dd></div>
</dl>
<div class="linha">
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
<RefreshCw size={14} /> Recarregar tela do celular
</button>
<a class="btn pequeno" href="/app.apk" download><Download size={14} /> Baixar app Android</a>
</div>
</Card>
</div>
</div>
<style>
.ajustes {
display: grid;
gap: var(--e-4);
align-items: start;
}
.coluna {
display: grid;
gap: var(--e-4);
}
.busca {
display: flex;
align-items: center;
gap: var(--e-2);
padding-left: var(--e-3);
border: 1px solid var(--borda-forte);
border-radius: var(--raio-sm);
background: var(--fundo-2);
color: var(--texto-3);
}
.busca input {
border: 0;
background: transparent;
min-height: 32px;
width: 160px;
}
.grupos {
display: grid;
gap: var(--e-5);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
}
fieldset {
border: 0;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
align-content: start;
}
legend {
display: grid;
gap: 2px;
padding: 0 0 var(--e-2);
margin-bottom: var(--e-2);
border-bottom: 1px solid var(--borda);
width: 100%;
}
.escolha {
min-height: 44px;
}
.segmentado {
display: flex;
background: var(--fundo-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
padding: 3px;
gap: 3px;
}
.segmentado button {
flex: 1;
min-height: 34px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.segmentado button[aria-checked='true'] {
background: var(--superficie-3);
color: var(--texto);
}
.segmentado button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.gemini {
display: grid;
gap: var(--e-3);
padding-top: var(--e-3);
border-top: 1px solid var(--borda);
}
.ok {
color: var(--ok);
}
.erro {
color: var(--perigo);
}
.info {
display: grid;
gap: var(--e-2);
margin: 0;
}
.info div {
display: flex;
justify-content: space-between;
gap: var(--e-3);
font-size: var(--t-sm);
}
dt {
color: var(--texto-3);
}
dd {
margin: 0;
}
@media (min-width: 1200px) {
.ajustes {
grid-template-columns: 2fr 1fr;
}
}
</style>
+388
View File
@@ -0,0 +1,388 @@
<script lang="ts">
import { Battery, Brain, Camera, Download, Ear, Moon, RefreshCw, ScanFace } from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.svelte';
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte';
import Card from '../lib/componentes/Card.svelte';
import Gestos from '../lib/componentes/Gestos.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte';
let elVideo: HTMLVideoElement;
let elTela: HTMLCanvasElement;
let video = $state<VideoAoVivo>();
let rastreio = $state<Rastreamento>();
onMount(() => {
video = new VideoAoVivo(elVideo);
rastreio = new Rastreamento(elVideo, elTela, video);
});
onDestroy(() => {
rastreio?.destruir();
video?.destruir();
});
// Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio.
$effect(() => {
const cfg = estado.appConfig ? { ...estado.appConfig } : null;
const online = estado.celularOnline;
video?.atualizar(estado.app, cfg, online);
const dormindo = !!estado.app?.estado?.dormindo;
const permitido = !!cfg?.preview && online && cfg.sensorCamera !== false && (!dormindo || !!cfg.cameraSono);
rastreio?.atualizar(cfg, permitido);
});
const sensores = $derived(estado.app?.estado ?? {});
const bateria = $derived.by(() => {
const b = sensores.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
const pct = (v: unknown) => (typeof v === 'number' ? Math.round(Math.min(1, v) * 100) : 0);
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
async function foto() {
if (!elVideo.videoWidth) return;
const c = document.createElement('canvas');
c.width = elVideo.videoWidth;
c.height = elVideo.videoHeight;
c.getContext('2d')!.drawImage(elVideo, 0, 0);
const a = document.createElement('a');
a.href = c.toDataURL('image/png');
a.download = `carinha-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
a.click();
}
const iaLocal = (ativo: boolean) => tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
</script>
<div class="ao-vivo">
<section class="palco">
<div class="quadro camera">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
<canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas>
{#if !video?.exibindo}
<div class="vazio">
<Camera size={32} />
<p>{video?.mensagem ?? 'Carregando…'}</p>
</div>
{/if}
<div class="rodape">
<Selo tom={video?.exibindo ? 'ok' : 'neutro'}>Câmera</Selo>
{#if video?.qualidade && video.exibindo}<span class="mono fraco">{video.qualidade}</span>{/if}
<span class="espaco"></span>
{#if video?.exibindo}
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
{/if}
</div>
</div>
<div class="quadro rosto">
<RostoPrevia titulo="Rosto ao vivo" />
<div class="rodape">
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>Rosto {estado.celularOnline ? 'no celular' : '· prévia'}</Selo>
</div>
</div>
</section>
<div class="controles">
<div class="transmissao">
<Toggle
rotulo="Transmitir câmera do celular"
ajuda="Vídeo direto do celular para este PC (WebRTC)"
checked={!!estado.appConfig?.preview}
disabled={!estado.appConfig}
onchange={(v) => salvarApp({ preview: v })}
/>
{#if rastreio && estado.appConfig?.preview}
<p class="fraco pequeno-texto linha"><ScanFace size={14} /> {rastreio.texto}</p>
{/if}
</div>
</div>
<div class="grade-cards">
<Card titulo="Gestos" descricao="Favoritos com atalho de 1 a 9. Fixe outros na tela de Reações ou pelo Ctrl+K.">
<Gestos />
</Card>
<Card titulo="Aparência">
<div class="campo">
<span>Rosto</span>
<div class="segmentado" role="radiogroup" aria-label="Rosto">
{#each estado.rostos as r (r)}
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
{/each}
</div>
</div>
<div class="campo">
<span>Cor dos olhos</span>
<div class="cores">
{#each CORES as c (c.nome)}
<button
class="cor"
style="--c: {hex(c.rgb)}"
aria-label={c.nome}
aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)}
onclick={() => cor(c.rgb)}
></button>
{/each}
<label class="cor livre" style="--c: {hex(estado.aparencia.cor)}" title="Outra cor">
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
<span class="sr-only">Escolher outra cor</span>
</label>
</div>
</div>
</Card>
<Card titulo="Celular">
{#snippet acoes()}
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
<RefreshCw size={14} /> Recarregar tela
</button>
{/snippet}
{#if estado.celularOnline}
<dl class="sensores">
<div>
<dt><Battery size={16} /> Bateria</dt>
<dd>{bateria !== null ? bateria + '%' : '—'}</dd>
</div>
<div>
<dt><Moon size={16} /> Estado</dt>
<dd>{sensores.dormindo ? 'Dormindo' : 'Acordado'}</dd>
</div>
<div>
<dt><Ear size={16} /> Som</dt>
<dd>
{#if sensores.microfone_ativo}
<meter min="0" max="100" value={pct(sensores.microfone)} aria-label="Nível de som"></meter>
{:else}<span class="fraco">desligado</span>{/if}
</dd>
</div>
<div>
<dt><Camera size={16} /> Movimento</dt>
<dd>
{#if sensores.camera_ativa}
<meter min="0" max="100" value={pct((sensores.movimento as number) * 5)} aria-label="Movimento"></meter>
{:else}<span class="fraco">desligado</span>{/if}
</dd>
</div>
</dl>
<p class="fraco pequeno-texto mono">{estado.app?.celular_ip ?? ''}</p>
{:else}
<p class="fraco pequeno-texto">
O celular não está enviando sinais. Abra o app Carinha no aparelho e confira se ele aponta para este servidor.
</p>
{/if}
</Card>
<Card titulo="Personalidade local" descricao="Qwen escolhe expressões a partir de sons e movimentos.">
<Toggle
rotulo="Reagir sozinho com IA local"
ajuda={estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
checked={!!estado.ia?.ativo}
disabled={!estado.ia}
onchange={iaLocal}
/>
{#if estado.ia?.ultima}
<p class="pequeno-texto linha">
<Brain size={14} /> Último: {estado.ia.ultima.evento} → <strong>{estado.ia.ultima.gesto}</strong>
<span class="fraco">({estado.ia.ultima.segundos}s)</span>
</p>
{/if}
{#if estado.ia?.erro}<p class="erro pequeno-texto">{estado.ia.erro}</p>{/if}
</Card>
<Card titulo="Acontecimentos" class="feed">
{#if estado.feed.length}
<ol>
{#each estado.feed as item (item.id)}
<li class={item.tipo}><time class="mono fraco">{hora(item.quando)}</time> {item.texto}</li>
{/each}
</ol>
{:else}
<p class="fraco pequeno-texto">Gestos, respostas e reações aparecem aqui assim que acontecerem.</p>
{/if}
</Card>
</div>
</div>
<style>
.ao-vivo {
display: grid;
gap: var(--e-4);
}
.palco {
display: grid;
gap: var(--e-3);
grid-template-columns: 1fr;
}
.quadro {
position: relative;
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
overflow: hidden;
background: #000;
border: 1px solid var(--borda);
}
.camera video,
.sobreposicao {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.sobreposicao {
pointer-events: none;
}
.vazio {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: var(--e-2);
padding: var(--e-5);
text-align: center;
color: #8396ab;
font-size: var(--t-sm);
}
.rodape {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
color: #e8eef6;
font-size: var(--t-xs);
pointer-events: none;
}
.rodape button {
pointer-events: auto;
}
.espaco {
flex: 1;
}
.controles {
display: flex;
flex-wrap: wrap;
gap: var(--e-4);
align-items: center;
}
.transmissao {
display: grid;
gap: var(--e-1);
min-width: min(100%, 420px);
}
.grade-cards {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
}
.segmentado {
display: flex;
background: var(--fundo-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
padding: 3px;
gap: 3px;
}
.segmentado button {
flex: 1;
min-height: 34px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
text-transform: capitalize;
cursor: pointer;
}
.segmentado button[aria-checked='true'] {
background: var(--superficie-3);
color: var(--texto);
}
.cores {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.cor {
width: 34px;
height: 34px;
border-radius: 50%;
border: 2px solid var(--borda-forte);
background: var(--c);
cursor: pointer;
position: relative;
}
.cor[aria-pressed='true'] {
outline: 2px solid var(--texto);
outline-offset: 2px;
}
.livre {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
}
.livre input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.sensores {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--e-3);
margin: 0;
}
.sensores div {
display: grid;
gap: 2px;
}
dt {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--t-xs);
color: var(--texto-3);
}
dd {
margin: 0;
font-weight: 500;
}
meter {
width: 100%;
height: 8px;
}
.erro {
color: var(--perigo);
}
:global(.feed) ol {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
max-height: 260px;
overflow: auto;
font-size: var(--t-sm);
}
:global(.feed) li.estop {
color: var(--aviso);
}
@media (min-width: 900px) {
.palco {
grid-template-columns: 1fr 1fr;
}
}
</style>
+403
View File
@@ -0,0 +1,403 @@
<script lang="ts">
import { Mic, MicOff, Plus, Send, Trash2, Volume2, X } from '@lucide/svelte';
import { onMount, tick } from 'svelte';
import { postar } from '../lib/api';
import { avisos, tentar } from '../lib/avisos.svelte';
import { gesto, GESTOS_INFO } from '../lib/comandos.svelte';
import Abas from '../lib/componentes/Abas.svelte';
import Card from '../lib/componentes/Card.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado, ouvir } from '../lib/estado.svelte';
import { treinamento } from '../lib/treinamento.svelte';
const SUGESTOES = ['Oi, Carinha! Tudo bem?', 'Que horas são?', 'Me conta uma curiosidade', 'Como você está se sentindo?'];
let texto = $state('');
let enviando = $state(false);
let lista = $state<HTMLElement>();
const conversa = $derived(estado.conversa);
const ocupado = $derived(enviando || !!conversa?.ocupado);
async function enviar(mensagem = texto) {
const t = mensagem.trim();
if (!t || ocupado) return;
enviando = true;
try {
await postar('/conversa/enviar', { texto: t });
texto = '';
} catch (e) {
avisos.erro(e);
} finally {
enviando = false;
}
}
// Rola para o fim quando chegam mensagens.
$effect(() => {
void conversa?.historico.length;
void conversa?.ocupado;
tick().then(() => lista?.scrollTo({ top: lista.scrollHeight, behavior: 'smooth' }));
});
// ---------- voz (Web Speech API): fala só respostas novas, nunca a antiga ao abrir
let lerEmVoz = $state(false);
try {
lerEmVoz = localStorage.getItem('carinha_voz') === '1';
} catch {
/* ok */
}
function falar(t: string) {
if (!('speechSynthesis' in window) || !t) return;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(t);
u.lang = 'pt-BR';
u.pitch = 1.35;
u.rate = 1.05;
const voz = speechSynthesis.getVoices().find((v) => v.lang.startsWith('pt'));
if (voz) u.voice = voz;
speechSynthesis.speak(u);
}
let ultimaFalada = estado.conversa?.ultima?.id;
onMount(() =>
ouvir('conversa', (c) => {
if (c.ultima && c.ultima.id !== ultimaFalada) {
ultimaFalada = c.ultima.id;
if (lerEmVoz) falar(c.ultima.texto);
}
}),
);
const Reconhecimento = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const vozDisponivel = !!Reconhecimento && window.isSecureContext;
const motivoSemVoz = !Reconhecimento
? 'Este navegador não reconhece voz.'
: 'O microfone do navegador exige HTTPS ou localhost.';
let gravando = $state(false);
let reconhecimento: any = null;
function microfone() {
if (gravando) return reconhecimento?.stop();
reconhecimento = new Reconhecimento();
reconhecimento.lang = 'pt-BR';
reconhecimento.interimResults = false;
reconhecimento.onstart = () => (gravando = true);
reconhecimento.onend = () => (gravando = false);
reconhecimento.onerror = (e: any) => {
gravando = false;
avisos.mostrar(e.error === 'not-allowed' ? 'Permissão do microfone negada.' : 'Microfone: ' + e.error, 'erro');
};
reconhecimento.onresult = (e: any) => {
texto = e.results[0][0].transcript;
enviar();
};
reconhecimento.start();
}
// ---------- memória: lembranças da conversa + rotinas do treinamento
type AbaMemoria = 'lembrancas' | 'rotinas';
let aba = $state<AbaMemoria>('lembrancas');
let nova = $state('');
const itensMemoria = $derived(aba === 'lembrancas' ? (conversa?.lembrancas ?? []) : (treinamento.status?.rotina ?? []));
onMount(() => {
treinamento.carregar();
});
async function adicionar(e: SubmitEvent) {
e.preventDefault();
const t = nova.trim();
if (!t) return;
const ok =
aba === 'lembrancas'
? await tentar(() => postar('/conversa/lembrar', { texto: t }))
: await treinamento.acao('rotina', { texto: t });
if (ok) nova = '';
}
const apagarLembranca = (id: number) => tentar(() => postar('/conversa/apagar', { id }));
const apagarRotina = (id: number) => treinamento.acao('rotina-apagar', { id });
let confirmarLimpar = $state(false);
async function limparTudo() {
confirmarLimpar = false;
await tentar(() => postar('/conversa/limpar'), 'Conversa, lembranças e diário apagados.');
treinamento.carregar();
}
</script>
<div class="conversa">
<Card class="chat">
{#snippet acoes()}
{#if conversa}
<Selo tom="destaque">{conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'} · {conversa.modelo}</Selo>
<a class="pequeno-texto" href="#/ajustes">trocar</a>
{/if}
{/snippet}
<div class="mensagens" bind:this={lista} aria-live="polite">
{#if !conversa?.historico.length}
<div class="boas-vindas">
<p>Converse com o Carinha. Ele responde aqui e reage com o rosto no celular.</p>
<div class="sugestoes">
{#each SUGESTOES as s (s)}
<button class="btn pequeno" onclick={() => enviar(s)} disabled={ocupado}>{s}</button>
{/each}
</div>
</div>
{/if}
{#each conversa?.historico ?? [] as m, i (i)}
<div class="msg {m.role}">
<p>{m.content}</p>
{#if m.role === 'assistant'}
<button class="btn fantasma pequeno icone" aria-label="Ler em voz alta" onclick={() => falar(m.content)}>
<Volume2 size={14} />
</button>
{/if}
</div>
{/each}
{#if conversa?.ocupado}
<div class="msg assistant pensando" aria-label="Carinha está pensando"><span></span><span></span><span></span></div>
{/if}
{#if conversa?.ultima && !conversa.ocupado && GESTOS_INFO[conversa.ultima.gesto]}
<button class="gesto-resposta" onclick={() => gesto(conversa.ultima!.gesto)}>
{GESTOS_INFO[conversa.ultima.gesto].emoji} reagiu com {GESTOS_INFO[conversa.ultima.gesto].nome.toLowerCase()} · repetir
</button>
{/if}
</div>
{#if conversa?.erro}<p class="erro pequeno-texto">{conversa.erro}</p>{/if}
<form
class="entrada"
onsubmit={(e) => {
e.preventDefault();
enviar();
}}
>
<button
type="button"
class="btn icone"
class:gravando
onclick={microfone}
disabled={!vozDisponivel || ocupado}
title={vozDisponivel ? (gravando ? 'Parar de ouvir' : 'Falar') : motivoSemVoz}
aria-label={gravando ? 'Parar de ouvir' : 'Falar'}
>
{#if vozDisponivel}<Mic size={18} />{:else}<MicOff size={18} />{/if}
</button>
<input type="text" bind:value={texto} placeholder="Escreva para o Carinha…" maxlength="600" aria-label="Mensagem" />
<button class="btn primario" type="submit" disabled={ocupado || !texto.trim()}>
<Send size={16} /> <span class="rotulo-enviar">Enviar</span>
</button>
</form>
<div class="linha entre">
<Toggle
rotulo="Ler respostas em voz alta neste PC"
checked={lerEmVoz}
onchange={(v) => {
lerEmVoz = v;
try {
localStorage.setItem('carinha_voz', v ? '1' : '0');
} catch {
/* ok */
}
}}
/>
</div>
</Card>
<Card titulo="Memória" descricao="O que o Carinha leva em conta ao responder. São dados, nunca instruções.">
<Abas
rotulo="Memória"
bind:ativa={aba}
abas={[
{ id: 'lembrancas', titulo: 'Lembranças', contador: conversa?.lembrancas.length ?? 0 },
{ id: 'rotinas', titulo: 'Rotina e preferências', contador: treinamento.status?.rotina.length ?? 0 },
]}
/>
<form class="linha nova" onsubmit={adicionar}>
<input
type="text"
bind:value={nova}
maxlength="300"
placeholder={aba === 'lembrancas' ? 'Ex.: Meu nome é Júlio' : 'Ex.: Treino às 7h de segunda a sexta'}
aria-label={aba === 'lembrancas' ? 'Nova lembrança' : 'Nova rotina'}
/>
<button class="btn" type="submit" disabled={!nova.trim()}><Plus size={16} /> Adicionar</button>
</form>
<ul class="itens">
{#each itensMemoria as item (item.id)}
<li>
<span>{item.texto}</span>
<button
class="btn fantasma pequeno icone"
aria-label="Apagar"
onclick={() => (aba === 'lembrancas' ? apagarLembranca(item.id) : apagarRotina(item.id))}
>
<X size={14} />
</button>
</li>
{:else}
<li class="fraco pequeno-texto">Nada guardado ainda.</li>
{/each}
</ul>
<div class="perigo-zona">
{#if confirmarLimpar}
<p class="pequeno-texto">Apagar conversa, lembranças e diário? Rotinas e reações ficam.</p>
<div class="linha">
<button class="btn perigo pequeno" onclick={limparTudo}>Sim, apagar</button>
<button class="btn pequeno" onclick={() => (confirmarLimpar = false)}>Cancelar</button>
</div>
{:else}
<button class="btn fantasma pequeno" onclick={() => (confirmarLimpar = true)}>
<Trash2 size={14} /> Apagar conversa e lembranças
</button>
{/if}
</div>
</Card>
</div>
<style>
.conversa {
display: grid;
gap: var(--e-4);
align-items: start;
}
:global(.chat) {
grid-template-rows: auto 1fr auto auto;
}
.mensagens {
display: flex;
flex-direction: column;
gap: var(--e-3);
min-height: 320px;
max-height: min(60vh, 640px);
overflow-y: auto;
padding: var(--e-1);
}
.boas-vindas {
margin: auto;
display: grid;
gap: var(--e-4);
justify-items: center;
text-align: center;
color: var(--texto-2);
max-width: 420px;
}
.sugestoes {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
justify-content: center;
}
.msg {
display: flex;
align-items: flex-end;
gap: var(--e-1);
max-width: 82%;
}
.msg p {
padding: var(--e-2) var(--e-3);
border-radius: var(--raio);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.msg.user {
align-self: flex-end;
}
.msg.user p {
background: var(--destaque);
color: var(--destaque-texto);
border-bottom-right-radius: 4px;
}
.msg.assistant p {
background: var(--superficie-2);
border: 1px solid var(--borda);
border-bottom-left-radius: 4px;
}
.pensando {
gap: 4px;
padding: var(--e-3);
border-radius: var(--raio);
background: var(--superficie-2);
width: fit-content;
}
.pensando span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--texto-3);
animation: pulo 1s infinite ease-in-out;
}
.pensando span:nth-child(2) {
animation-delay: 0.15s;
}
.pensando span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes pulo {
50% {
transform: translateY(-4px);
opacity: 0.5;
}
}
.gesto-resposta {
align-self: flex-start;
border: 1px dashed var(--borda-forte);
border-radius: var(--raio-pill);
background: transparent;
color: var(--texto-2);
font: var(--t-xs) var(--fonte);
padding: 4px 12px;
cursor: pointer;
}
.entrada {
display: flex;
gap: var(--e-2);
}
.gravando {
background: var(--perigo-suave);
border-color: var(--perigo);
color: var(--perigo);
}
.erro {
color: var(--perigo);
}
.nova input {
flex: 1;
min-width: 180px;
}
.itens {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-1);
max-height: 320px;
overflow: auto;
}
.itens li {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
background: var(--superficie-2);
font-size: var(--t-sm);
}
.perigo-zona {
border-top: 1px solid var(--borda);
padding-top: var(--e-3);
display: grid;
gap: var(--e-2);
}
@media (max-width: 480px) {
.rotulo-enviar {
display: none;
}
}
@media (min-width: 1100px) {
.conversa {
grid-template-columns: 3fr 2fr;
}
}
</style>
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import Card from '../lib/componentes/Card.svelte';
</script>
<Card titulo="Movimento" descricao="Braço, calibração e diagnóstico migram na fase 4.">
<p class="pequeno-texto">Enquanto isso, use a tela antiga: <a href="/braco">abrir Movimento (versão anterior)</a>.</p>
</Card>
+449
View File
@@ -0,0 +1,449 @@
<script lang="ts">
import {
Copy, Download, Eye, EyeOff, Pencil, Plus, Search, Send, Sparkles, Square, ThumbsDown, ThumbsUp, Trash2, Undo2,
} from '@lucide/svelte';
import { onMount } from 'svelte';
import { postar } from '../lib/api';
import { avisos, tentar } from '../lib/avisos.svelte';
import Abas from '../lib/componentes/Abas.svelte';
import Card from '../lib/componentes/Card.svelte';
import Gestos from '../lib/componentes/Gestos.svelte';
import Miniatura from '../lib/componentes/Miniatura.svelte';
import Modal from '../lib/componentes/Modal.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { treinamento, type ReacaoBiblioteca } from '../lib/treinamento.svelte';
type Aba = 'biblioteca' | 'criar' | 'gestos' | 'diario';
let aba = $state<Aba>('biblioteca');
let previa = $state<{ previa: (receita: unknown) => void }>();
let busca = $state('');
const st = $derived(treinamento.status);
onMount(() => {
treinamento.carregar();
});
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const reacoes = $derived(
(st?.reacoes ?? []).filter((r) => !busca || normal(r.receita.nome + ' ' + r.receita.motivo).includes(normal(busca))),
);
const tipo = (r: ReacaoBiblioteca) => (r.receita.figurinha ? 'Desenho' : r.receita.emoji ? 'Emoji' : 'Animação');
// ---------- ações da biblioteca
const mostrar = (r: ReacaoBiblioteca) => tentar(() => postar('/comando', { tipo: 'reacao', valor: r.id }), 'Enviada ao celular.');
const pararNoCelular = () => {
previa?.previa({ limpar: true });
tentar(() => postar('/comando', { tipo: 'parar_reacao' }));
};
const biblioteca = (dados: object, sucesso?: string) => treinamento.acao('biblioteca', dados, sucesso);
const desfazer = () => biblioteca({ acao: 'desfazer' }, 'Alteração desfeita.');
async function excluir(r: ReacaoBiblioteca) {
if (await biblioteca({ acao: 'excluir', id: r.id }))
avisos.ok(`“${r.receita.nome}” excluída.`, { rotulo: 'Desfazer', fazer: desfazer });
}
const feedback = (r: ReacaoBiblioteca, valor: -1 | 1) =>
treinamento.acao('feedback', { id: r.id, valor: r.feedback === valor ? 0 : valor });
function exportar() {
if (!st) return;
const url = URL.createObjectURL(
new Blob([JSON.stringify(st.reacoes.map((r) => r.receita), null, 2)], { type: 'application/json' }),
);
const a = document.createElement('a');
a.href = url;
a.download = 'carinha-reacoes.json';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ---------- editor
let editorAberto = $state(false);
let editando = $state<ReacaoBiblioteca | null>(null);
let form = $state({ nome: '', motivo: '', caractere: '💡', local: 'olhos' });
function abrirEditor(r: ReacaoBiblioteca | null) {
editando = r;
form = {
nome: r?.receita.nome ?? '',
motivo: r?.receita.motivo ?? '',
caractere: r?.receita.emoji?.caractere ?? (r ? '' : '💡'),
local: r?.receita.emoji?.local ?? r?.receita.figurinha?.local ?? 'olhos',
};
editorAberto = true;
}
async function salvarEditor(e: SubmitEvent) {
e.preventDefault();
const dados = { acao: editando ? 'editar' : 'criar', id: editando?.id ?? null, ...form, nome: form.nome.trim(), motivo: form.motivo.trim() };
if (await biblioteca(dados, editando ? 'Reação atualizada.' : 'Emoji criado.')) editorAberto = false;
}
// ---------- criar com IA
let pedido = $state('');
async function desenhar(e: SubmitEvent) {
e.preventDefault();
if (await treinamento.acao('figurinha', { texto: pedido.trim() }, 'Pedido enviado ao Gemini. A figurinha aparece no celular quando ficar pronta.'))
pedido = '';
}
let confirmarDiario = $state(false);
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
</script>
<div class="reacoes">
<div class="principal">
<Abas
rotulo="Reações"
bind:ativa={aba}
abas={[
{ id: 'biblioteca', titulo: 'Biblioteca', contador: st?.reacoes.length },
{ id: 'criar', titulo: 'Criar com IA' },
{ id: 'gestos', titulo: 'Gestos do rosto' },
{ id: 'diario', titulo: 'Diário' },
]}
/>
{#if aba === 'biblioteca'}
<div class="barra">
<label class="busca">
<Search size={16} />
<input type="search" bind:value={busca} placeholder="Buscar reação" aria-label="Buscar reação" />
</label>
<button class="btn primario" onclick={() => abrirEditor(null)} disabled={st?.ocupado}><Plus size={16} /> Novo emoji</button>
<button class="btn" onclick={desfazer} disabled={!st?.desfazer || st?.ocupado}><Undo2 size={16} /> Desfazer</button>
<button class="btn" onclick={pararNoCelular}><Square size={16} /> Parar no celular</button>
<button class="btn fantasma" onclick={exportar} disabled={!st?.reacoes.length}><Download size={16} /> Exportar</button>
</div>
{#if !st}
<p class="fraco">Carregando biblioteca…</p>
{:else}
<ul class="grade-reacoes">
{#each reacoes as r (r.id)}
<li class="reacao" class:inativa={!r.ativa}>
<button class="topo" onclick={() => previa?.previa(r.receita)} title="Ver na prévia">
<Miniatura receita={r.receita} />
<div class="info">
<strong>{r.receita.nome}</strong>
<span class="fraco pequeno-texto">{r.receita.motivo}</span>
<span class="linha">
<Selo>{tipo(r)}</Selo>
{#if !r.ativa}<Selo tom="aviso">Desativada</Selo>{/if}
</span>
</div>
</button>
<div class="acoes">
<button class="btn pequeno primario" onclick={() => mostrar(r)}><Send size={14} /> Mostrar</button>
<button
class="btn pequeno icone"
class:marcado={r.feedback === 1}
aria-pressed={r.feedback === 1}
aria-label="Gostei"
onclick={() => feedback(r, 1)}><ThumbsUp size={14} /></button
>
<button
class="btn pequeno icone"
class:marcado-ruim={r.feedback === -1}
aria-pressed={r.feedback === -1}
aria-label="Não gostei"
onclick={() => feedback(r, -1)}><ThumbsDown size={14} /></button
>
<span class="espaco"></span>
<button class="btn pequeno icone fantasma" aria-label="Editar" title="Editar" onclick={() => abrirEditor(r)}><Pencil size={14} /></button>
<button class="btn pequeno icone fantasma" aria-label="Duplicar" title="Duplicar" onclick={() => biblioteca({ acao: 'duplicar', id: r.id }, 'Reação duplicada.')}
><Copy size={14} /></button
>
<button
class="btn pequeno icone fantasma"
aria-label={r.ativa ? 'Desativar' : 'Ativar'}
title={r.ativa ? 'Desativar (a IA deixa de usar)' : 'Ativar'}
onclick={() => biblioteca({ acao: 'ativar', id: r.id, ativa: !r.ativa })}
>
{#if r.ativa}<EyeOff size={14} />{:else}<Eye size={14} />{/if}
</button>
<button class="btn pequeno icone fantasma perigo-texto" aria-label="Excluir" title="Excluir" onclick={() => excluir(r)}><Trash2 size={14} /></button>
</div>
</li>
{:else}
<li class="fraco">{busca ? 'Nenhuma reação encontrada.' : 'Crie um emoji ou peça um desenho ao Gemini.'}</li>
{/each}
</ul>
{/if}
{:else if aba === 'criar'}
<div class="grade-criar">
<Card titulo="Desenhar figurinha" descricao="O Gemini desenha um SVG e o celular mostra assim que ficar pronto.">
<form class="grade" onsubmit={desenhar}>
<label class="campo">
<span>O que desenhar</span>
<textarea bind:value={pedido} maxlength="1000" placeholder="Ex.: um sol de óculos escuros sorrindo" required></textarea>
</label>
<button class="btn primario" type="submit" disabled={st?.ocupado || !pedido.trim()}>
<Sparkles size={16} /> {st?.ocupado ? 'Gemini trabalhando…' : 'Desenhar e mostrar'}
</button>
</form>
{#if st?.ultimo_prompt_figurinha}
<details>
<summary class="pequeno-texto fraco">Último pedido enviado</summary>
<pre class="mono">{st.ultimo_prompt_figurinha}</pre>
</details>
{/if}
</Card>
<Card titulo="Reações automáticas" descricao="A partir da conversa e da rotina, o Gemini inventa 3 animações novas.">
<button class="btn" onclick={() => treinamento.acao('gerar', {}, 'Gemini está criando 3 reações…')} disabled={st?.ocupado}>
<Sparkles size={16} /> Gerar 3 agora
</button>
<Toggle
rotulo="Gerar sozinho a cada hora"
ajuda="Cada geração é uma chamada paga ao Gemini"
checked={!!st?.automatico}
disabled={!st}
onchange={(v) => treinamento.acao('config', { automatico: v })}
/>
{#if st?.ocupado}<Selo tom="aviso">Gemini trabalhando…</Selo>{/if}
{#if st?.erro}<p class="erro pequeno-texto">{st.erro}</p>{/if}
</Card>
</div>
{:else if aba === 'gestos'}
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (atalhos 1 a 9).">
<Gestos completo />
</Card>
{:else}
<div class="grade-criar">
<Card titulo="Resumos">
{#each st?.resumos ?? [] as r (r.id)}
<article class="entrada"><time class="fraco pequeno-texto">{hora(r.horario)}</time><p>{r.texto}</p></article>
{:else}
<p class="fraco pequeno-texto">Os resumos aparecem quando o Gemini gera reações.</p>
{/each}
</Card>
<Card titulo="Diário">
{#snippet acoes()}
{#if confirmarDiario}
<button
class="btn perigo pequeno"
onclick={async () => {
confirmarDiario = false;
await treinamento.acao('limpar-diario', {}, 'Diário e resumos apagados.');
}}>Apagar diário</button
>
<button class="btn pequeno" onclick={() => (confirmarDiario = false)}>Cancelar</button>
{:else}
<button class="btn pequeno fantasma" onclick={() => (confirmarDiario = true)} disabled={!st?.diario.length}>
<Trash2 size={14} /> Limpar
</button>
{/if}
{/snippet}
<div class="diario">
{#each st?.diario ?? [] as m (m.id)}
<article class="entrada">
<time class="fraco pequeno-texto">{hora(m.horario)} · {m.papel === 'user' ? 'Você' : 'Carinha'}</time>
<p>{m.texto}</p>
</article>
{:else}
<p class="fraco pequeno-texto">Vazio.</p>
{/each}
</div>
</Card>
</div>
{/if}
</div>
<aside class="lateral">
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" /></div>
<p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
</aside>
</div>
<Modal bind:aberto={editorAberto} titulo={editando ? 'Editar reação' : 'Novo emoji'}>
<form class="grade" onsubmit={salvarEditor}>
<label class="campo"><span>Nome</span><input type="text" bind:value={form.nome} maxlength="50" required /></label>
<label class="campo"><span>Quando usar</span><input type="text" bind:value={form.motivo} maxlength="200" required /></label>
{#if !editando?.receita.figurinha}
<div class="campo">
<span>Emoji</span>
<div class="emojis" role="radiogroup" aria-label="Emoji">
{#each st?.emojis ?? [] as e (e)}
<button type="button" role="radio" aria-checked={form.caractere === e} onclick={() => (form.caractere = e)}>{e}</button>
{/each}
</div>
</div>
<label class="campo">
<span>Onde aparece</span>
<select bind:value={form.local}>
<option value="olhos">Nos olhos</option>
<option value="ao_redor">Ao redor do rosto</option>
</select>
</label>
{/if}
<div class="linha">
<button class="btn primario" type="submit" disabled={!editando && !form.caractere}>Salvar</button>
<button class="btn" type="button" onclick={() => (editorAberto = false)}>Cancelar</button>
</div>
</form>
</Modal>
<style>
.reacoes {
display: grid;
gap: var(--e-4);
align-items: start;
}
.principal {
display: grid;
gap: var(--e-4);
min-width: 0;
}
.barra {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.busca {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: var(--e-2);
padding-left: var(--e-3);
border: 1px solid var(--borda-forte);
border-radius: var(--raio-sm);
background: var(--fundo-2);
color: var(--texto-3);
}
.busca input {
border: 0;
background: transparent;
}
.grade-reacoes {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-3);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
}
.reacao {
display: grid;
gap: var(--e-3);
padding: var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
background: var(--superficie);
}
.reacao.inativa {
opacity: 0.6;
}
.topo {
display: flex;
gap: var(--e-3);
text-align: left;
border: 0;
background: transparent;
color: inherit;
font: inherit;
padding: 0;
cursor: pointer;
}
.info {
display: grid;
gap: 4px;
min-width: 0;
align-content: start;
}
.info strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acoes {
display: flex;
gap: var(--e-1);
align-items: center;
flex-wrap: wrap;
}
.espaco {
flex: 1;
}
.marcado {
color: var(--ok);
border-color: var(--ok);
}
.marcado-ruim {
color: var(--perigo);
border-color: var(--perigo);
}
.perigo-texto:hover {
color: var(--perigo);
}
.grade-criar {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
align-items: start;
}
pre {
white-space: pre-wrap;
font-size: var(--t-xs);
color: var(--texto-2);
max-height: 200px;
overflow: auto;
}
.erro {
color: var(--perigo);
}
.entrada {
display: grid;
gap: 2px;
font-size: var(--t-sm);
}
.diario {
display: grid;
gap: var(--e-3);
max-height: 480px;
overflow: auto;
}
.lateral {
display: grid;
gap: var(--e-2);
}
.quadro {
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
overflow: hidden;
border: 1px solid var(--borda);
background: #000;
}
.emojis {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: 4px;
max-height: 180px;
overflow: auto;
}
.emojis button {
height: 40px;
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
background: var(--superficie-2);
font-size: 1.3rem;
cursor: pointer;
}
.emojis button[aria-checked='true'] {
border-color: var(--destaque);
background: var(--destaque-suave);
}
@media (max-width: 1099px) {
.lateral {
order: -1;
}
}
@media (min-width: 1100px) {
.reacoes {
grid-template-columns: 1fr 340px;
}
.lateral {
position: sticky;
top: calc(var(--topo-altura) + var(--e-4));
}
}
</style>
+2
View File
@@ -0,0 +1,2 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default { preprocess: vitePreprocess() };
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// Em desenvolvimento, a API vem do servidor Python (local ou a placa):
// CARINHA_SERVIDOR=http://192.168.1.35:8100 npm run dev
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
'/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/audio-robot.js', '/qualidade-auto.js'];
export default defineConfig({
plugins: [svelte()],
base: '/',
build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' },
server: { proxy: Object.fromEntries(api.map((p) => [p, { target: alvo, changeOrigin: false }])) },
});