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>
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user