feat(vida): jeito de gente e console com o porquê de cada decisão

Console (console.py + tela Console no painel):
- cada parte registra o que fez e o motivo: modo vida (com necessidades
  e humor), sentidos, celular (ordens e o que o rosto fez), IA local,
  aprendizado e ideias do dia; últimas 400 linhas, ao vivo pelo SSE;
- tela com o "agora" (humor, necessidades, o que está fazendo, próxima
  decisão, última decisão, jeito), filtros, detalhes, pausa e busca.

Jeito de gente:
- saudade: a festa cresce com o tempo fora (aceno, "Oi! Você voltou!",
  "Senti sua falta!"); bom dia lembrando de ontem;
- pedidos com motivo ("Brinca comigo?", "Me faz um carinho?", mostrar a
  figurinha nova), no máximo 1 a cada 20 min;
- larga atividade no meio quando fica chato; personalidade (curiosidade,
  brincadeira, timidez) que pesa nos gestos e amadurece com o convívio;
- no rosto: acordar grogue, sonhar com algo do dia (balão no motor),
  hesitar antes de gestos, errar na atividade e ficar sem graça, olhar
  nos olhos e desviar, sorrir de volta (FaceLandmarker no rastreador do
  painel) e reagir à luz (sensor de luz no app 0.10.0);
- ordem "dizer" com legenda e voz de robô.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-11 03:37:07 -03:00
co-authored by Claude Opus 5.5
parent a99aa897c4
commit 2933407b9e
27 changed files with 1248 additions and 33 deletions
+4 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Terminal, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte';
@@ -21,6 +21,7 @@
import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.svelte';
import DiaADia from './telas/DiaADia.svelte';
import Console from './telas/Console.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia';
@@ -35,10 +36,12 @@
guia: Guia,
criar: Criar,
'dia-a-dia': DiaADia,
console: Console,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
eventos: Bell,
console: Terminal,
conversa: MessageCircle,
reacoes: Smile,
movimento: Hand,
+1 -1
View File
@@ -188,7 +188,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu';
};
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao']) {
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao', 'console']) {
fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados);
+33 -2
View File
@@ -229,6 +229,37 @@ export const TOPICOS: Record<string, TopicoGuia> = {
{ emoji: '⭐', nome: 'Favoritos', texto: 'A estrela põe expressões e atividades criadas na grade de favoritos do Ao vivo (e nas teclas 1 a 9).' },
],
},
console: {
titulo: 'Console',
emoji: '🖥️',
onde: 'Console (menu lateral)',
resumo:
'Tudo o que ele faz, ao vivo, com o porquê. Em cima, o agora: humor, necessidades, o que está fazendo, quando decide de novo e o jeito dele. Embaixo, cada decisão com o motivo (ex.: "Ficou exausto e foi dormir, porque energia 12: abaixo de 20"). Os filtros escolhem de onde vêm as linhas; "Detalhes" mostra também o que se repete muito (humor reenviado, disparo solto do sensor). Pausar segura a tela para ler.',
itens: [
{ emoji: '🫀', nome: 'Modo vida', texto: 'As decisões dele: dormir, atividades, gestos, pedidos, reencontros.' },
{ emoji: '👀', nome: 'Sentidos', texto: 'Carinho, toque, conversa, presença, som e movimento, com o efeito nas necessidades.' },
{ emoji: '📱', nome: 'Celular', texto: 'O que foi mandado ao rosto e o que o rosto fez (dormiu, acordou, começou atividade).' },
{ emoji: '🧠', nome: 'IA local / Aprendizado / Ideias', texto: 'Escolhas da IA do S10, decisões tiradas da rotina e as ideias do dia.' },
],
},
jeito: {
titulo: 'Jeito de gente',
emoji: '🧒',
onde: 'Modo vida e rosto',
resumo: 'O que faz ele parecer alguém, e não um programa.',
itens: [
{ emoji: '🤗', nome: 'Saudade', texto: 'A festa depende do tempo fora: 10 min, um aceno; 1 h, "Oi! Você voltou!"; 4 h ou mais, "Senti sua falta!".' },
{ emoji: '☀️', nome: 'Bom dia com lembrança', texto: 'No primeiro encontro do dia lembra de ontem: "ontem a gente ficou acordado até as 2h, hein!".' },
{ emoji: '🥱', nome: 'Acordar grogue', texto: 'Depois de dormir um tempo, acorda com os olhos pesados, lento, e boceja antes de ficar esperto.' },
{ emoji: '💭', nome: 'Sonhar', texto: 'Dormindo, às vezes aparece bem fraco um balão com algo do dia (a bola, as estrelas) e ele sorri.' },
{ emoji: '🙈', nome: 'Imperfeição', texto: 'Hesita antes de alguns gestos, erra no meio da atividade e fica sem graça, e às vezes larga uma atividade porque ficou chato.' },
{ emoji: '🙋', nome: 'Pedidos', texto: 'Com alguém perto, pede o que falta ("Brinca comigo?", "Me faz um carinho?") ou mostra a figurinha que desenhou. No máximo um pedido a cada 20 min.' },
{ emoji: '👁️', nome: 'Contato visual', texto: 'Com o rastreamento de rosto, olha nos olhos alguns segundos, desvia um instante e volta.' },
{ emoji: '😊', nome: 'Sorriso de volta', texto: 'Com o Ao vivo aberto e o rastreamento de rosto ligado, se você sorri ele sorri (ou ri) de volta.' },
{ emoji: '💡', nome: 'Luz', texto: 'Apagaram a luz: boceja e fica com sono. Acenderam de repente: aperta os olhos.' },
{ emoji: '🧬', nome: 'Personalidade', texto: 'Curioso, brincalhão e tímido em medidas próprias. Muda o jeito de tudo e amadurece: carinho deixa menos tímido, brincar deixa mais brincalhão.' },
],
},
ideias: {
titulo: 'Ideias do dia',
emoji: '💡',
@@ -371,13 +402,13 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia',
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'jeito', 'console', 'criar', 'ideias', 'aparencia',
'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos',
];
/** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
'ao-vivo': 'inicio', console: 'console', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
};
+2
View File
@@ -18,6 +18,8 @@ export interface AlvoRastreio {
x: number;
y: number;
atividade?: boolean;
/** 0 a 1: o quanto a pessoa está sorrindo (só rosto; o celular sorri de volta) */
sorriso?: number;
}
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
+2
View File
@@ -11,6 +11,7 @@ interface Resultado {
hands: Ponto[][];
ms: number;
timestamp: number;
sorriso?: number | null;
}
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],
@@ -146,6 +147,7 @@ export class Rastreamento {
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;
if (alvo && alvo.tipo === 'rosto' && typeof r.sorriso === 'number') alvo.sorriso = Math.round(r.sorriso * 100) / 100;
this.alvo = alvo;
this.alvoEm = r.timestamp;
this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
+2 -1
View File
@@ -1,9 +1,10 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia';
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia' | 'console';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'console', titulo: 'Console', atalho: 'g k' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
+9 -1
View File
@@ -104,7 +104,15 @@ export interface Vida {
sono_profundo?: boolean;
/** '' quando pode inventar figurinha agora; senão, o motivo. */
pode_criar: string;
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | 'aprendizado' | 'aprendi'; texto: string }[];
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | 'aprendizado' | 'aprendi' | 'social' | 'pedido' | 'ideia'; texto: string }[];
/** Traços de 0 a 1 (curiosidade, brincadeira, timidez): mudam o jeito e amadurecem com o convívio. */
personalidade?: Record<string, number>;
/** Motivo da última decisão do modo vida. */
porque?: string;
/** A última decisão de fato (o que fez e por quê). */
ultima?: { horario: number; texto: string; porque: string } | null;
/** Segundos até a próxima decisão. */
proximo_ato_em?: number;
}
/** O que ele aprendeu com o dia a dia (aprendizado.py). */
+348
View File
@@ -0,0 +1,348 @@
<script lang="ts">
import { Pause, Play, Search, Trash2 } from '@lucide/svelte';
import { onMount } from 'svelte';
import { pedir } from '../lib/api';
import { avisos } from '../lib/avisos.svelte';
import { estado, ouvir } from '../lib/estado.svelte';
// Console: tudo o que ele está fazendo e por quê, ao vivo. Em cima o "agora"; embaixo cada decisão
// com o motivo (modo vida, sentidos, celular, IA local, aprendizado, ideias do dia).
type Origem = 'vida' | 'sentidos' | 'celular' | 'ia' | 'aprendizado' | 'ideias' | 'sistema';
interface Linha { id: number; horario: number; origem: Origem; texto: string; motivo: string; nivel: 'info' | 'detalhe' }
const ORIGENS: { id: Origem; nome: string }[] = [
{ id: 'vida', nome: 'Modo vida' }, { id: 'sentidos', nome: 'Sentidos' }, { id: 'celular', nome: 'Celular' },
{ id: 'ia', nome: 'IA local' }, { id: 'aprendizado', nome: 'Aprendizado' }, { id: 'ideias', nome: 'Ideias' },
{ id: 'sistema', nome: 'Sistema' },
];
const MAX = 600;
let linhas = $state<Linha[]>([]);
let pausado = $state(false);
let guardadas = $state<Linha[]>([]); // chegaram com o console pausado
let detalhes = $state(false);
let busca = $state('');
let ligadas = $state<Record<Origem, boolean>>({ vida: true, sentidos: true, celular: true, ia: true, aprendizado: true, ideias: true, sistema: true });
let vidaEm = $state(Date.now());
onMount(() => {
pedir<{ linhas: Linha[] }>('/console').then((d) => (linhas = d.linhas.slice(-MAX).reverse())).catch((e) => avisos.erro(e));
return ouvir('console', (l: Linha) => {
if (pausado) guardadas = [l, ...guardadas].slice(0, MAX);
else linhas = [l, ...linhas].slice(0, MAX);
});
});
$effect(() => {
estado.vida; // a contagem do próximo ato parte de quando o status chegou
vidaEm = Date.now();
});
function alternarPausa() {
pausado = !pausado;
if (!pausado && guardadas.length) {
linhas = [...guardadas, ...linhas].slice(0, MAX);
guardadas = [];
}
}
const visiveis = $derived.by(() => {
const q = busca.trim().toLowerCase();
return linhas.filter((l) => ligadas[l.origem] && (detalhes || l.nivel !== 'detalhe') &&
(!q || (l.texto + ' ' + l.motivo).toLowerCase().includes(q)));
});
const hora = (s: number) => new Date(s * 1000).toLocaleTimeString('pt-BR');
// ---------------------------------------------------------------- agora
const v = $derived(estado.vida);
const cel = $derived(estado.app);
const proximo = $derived(v?.proximo_ato_em != null ? Math.max(0, Math.round(v.proximo_ato_em - (estado.agora - vidaEm) / 1000)) : null);
const fazendo = $derived.by(() => {
if (!cel?.online) return 'celular desconectado';
const e = cel.estado ?? {};
if (e.atividade) return 'fazendo: ' + e.atividade;
if (e.dormindo) return v?.sono_profundo ? 'dormindo (sono pesado)' : 'dormindo';
return 'acordado, de bobeira';
});
const noite = $derived(estado.appConfig ? `${estado.appConfig.noiteInicio ?? 23}h às ${estado.appConfig.noiteFim ?? 7}h` : '');
const NEC: { id: 'energia' | 'alegria' | 'social' | 'diversao'; nome: string }[] = [
{ id: 'energia', nome: 'Energia' }, { id: 'alegria', nome: 'Alegria' }, { id: 'social', nome: 'Social' }, { id: 'diversao', nome: 'Diversão' },
];
const TRACO: Record<string, string> = { curiosidade: 'Curioso', brincadeira: 'Brincalhão', timidez: 'Tímido' };
</script>
<div class="console">
<section class="agora" aria-label="O que ele está fazendo agora">
<div class="quem">
<span class="emoji" aria-hidden="true">{v?.emoji ?? '…'}</span>
<div>
<strong>{v?.nome_humor ?? 'Carregando'}</strong>
<small>{fazendo}</small>
</div>
</div>
<div class="necessidades">
{#each NEC as n (n.id)}
{@const valor = v?.necessidades[n.id] ?? 0}
<div class="nec">
<span>{n.nome}</span>
<span class="barra" aria-hidden="true"><span style:width="{valor}%" class:baixa={valor < 35}></span></span>
<span class="mono">{valor}</span>
</div>
{/each}
</div>
<dl class="fatos">
<div><dt>Próxima decisão</dt><dd class="mono">{!v?.config.ligado ? 'modo vida desligado' : proximo === null ? '—' : `em ${proximo} s`}</dd></div>
<div><dt>Última decisão</dt><dd>{#if v?.ultima}{v.ultima.texto}<small class="porque">{hora(v.ultima.horario)}{v.ultima.porque ? ' · ' + v.ultima.porque : ''}</small>{:else}—{/if}</dd></div>
<div><dt>Presença</dt><dd>{estado.pir?.ativo ? 'sensor vendo alguém' : estado.pir?.presenca?.estado === 'ausente' ? 'ninguém por perto' : 'ninguém agora'}</dd></div>
<div><dt>Madrugada</dt><dd>{noite}</dd></div>
{#if v?.personalidade}
<div><dt>Jeito</dt><dd>{Object.entries(v.personalidade).map(([k, x]) => `${TRACO[k] ?? k} ${Math.round(x * 100)}%`).join(' · ')}</dd></div>
{/if}
</dl>
</section>
<section class="terminal" aria-label="Registro ao vivo">
<div class="barra-ferramentas">
<div class="filtros" role="group" aria-label="Mostrar">
{#each ORIGENS as o (o.id)}
<button class="chip o-{o.id}" aria-pressed={ligadas[o.id]} onclick={() => (ligadas[o.id] = !ligadas[o.id])}>{o.nome}</button>
{/each}
</div>
<div class="acoes">
<label class="busca"><Search size={14} /><input type="search" placeholder="Procurar" bind:value={busca} aria-label="Procurar no console" /></label>
<label class="detalhes"><input type="checkbox" bind:checked={detalhes} /> Detalhes</label>
<button class="btn pequeno" onclick={alternarPausa} aria-pressed={pausado}>
{#if pausado}<Play size={14} /> Continuar{guardadas.length ? ` (${guardadas.length})` : ''}{:else}<Pause size={14} /> Pausar{/if}
</button>
<button class="btn pequeno fantasma" onclick={() => { linhas = []; guardadas = []; }} aria-label="Limpar a tela"><Trash2 size={14} /></button>
</div>
</div>
<ol class="linhas" aria-live="polite">
{#each visiveis as l (l.id)}
<li class:detalhe={l.nivel === 'detalhe'}>
<time class="mono">{hora(l.horario)}</time>
<span class="tag o-{l.origem}">{ORIGENS.find((o) => o.id === l.origem)?.nome ?? l.origem}</span>
<span class="texto">{l.texto}{#if l.motivo}<span class="motivo">porque {l.motivo}</span>{/if}</span>
</li>
{:else}
<li class="vazio">Nada por aqui ainda. Cada decisão dele aparece aqui na hora, com o motivo.</li>
{/each}
</ol>
</section>
</div>
<style>
.console {
display: grid;
gap: var(--e-4);
}
.agora {
display: grid;
gap: var(--e-4);
padding: var(--e-4);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
background: var(--superficie);
}
@media (min-width: 1024px) {
.agora {
grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1fr) minmax(0, 2fr);
align-items: start;
}
}
.quem {
display: flex;
gap: var(--e-3);
align-items: center;
}
.quem .emoji {
font-size: 2.4rem;
line-height: 1;
}
.quem strong {
display: block;
font-size: var(--t-lg);
}
.quem small {
color: var(--texto-2);
}
.necessidades {
display: grid;
gap: 6px;
}
.nec {
display: grid;
grid-template-columns: 64px 1fr 28px;
gap: var(--e-2);
align-items: center;
font-size: var(--t-xs);
color: var(--texto-2);
}
.barra {
height: 6px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
overflow: hidden;
}
.barra span {
display: block;
height: 100%;
background: var(--destaque);
transition: width var(--transicao);
}
.barra span.baixa {
background: var(--aviso);
}
.nec .mono {
text-align: right;
font-variant-numeric: tabular-nums;
}
.fatos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--e-2) var(--e-4);
margin: 0;
}
.fatos div {
min-width: 0;
}
.fatos dt {
font-size: var(--t-xs);
color: var(--texto-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fatos dd {
margin: 2px 0 0;
font-size: var(--t-sm);
overflow-wrap: anywhere;
}
.porque {
display: block;
color: var(--texto-3);
font-size: var(--t-xs);
}
.terminal {
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
background: var(--fundo-2);
overflow: hidden;
}
.barra-ferramentas {
display: flex;
flex-wrap: wrap;
gap: var(--e-2) var(--e-3);
justify-content: space-between;
align-items: center;
padding: var(--e-3);
border-bottom: 1px solid var(--borda);
background: var(--superficie);
}
.filtros, .acoes {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.chip {
min-height: 28px;
padding: 0 var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: transparent;
color: var(--texto-3);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.chip[aria-pressed='true'] {
color: var(--texto);
border-color: var(--cor);
background: color-mix(in srgb, var(--cor) 14%, transparent);
}
.busca {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 0 var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
color: var(--texto-3);
}
.busca input {
width: 140px;
min-height: 28px;
border: 0;
background: transparent;
color: var(--texto);
font: inherit;
font-size: var(--t-xs);
outline: none;
}
.detalhes {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: var(--t-xs);
color: var(--texto-2);
}
.linhas {
list-style: none;
margin: 0;
padding: var(--e-2) 0;
max-height: min(70vh, 900px);
overflow-y: auto;
font-size: var(--t-sm);
}
.linhas li {
display: grid;
grid-template-columns: 72px 104px minmax(0, 1fr);
gap: var(--e-3);
align-items: baseline;
padding: 5px var(--e-4);
border-bottom: 1px solid color-mix(in srgb, var(--borda) 45%, transparent);
}
@media (max-width: 640px) {
.linhas li {
grid-template-columns: 64px minmax(0, 1fr);
}
.linhas li .texto {
grid-column: 1 / -1;
}
}
.linhas li.detalhe {
opacity: 0.6;
}
.linhas li.vazio {
display: block;
color: var(--texto-3);
}
time {
color: var(--texto-3);
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
}
.tag {
justify-self: start;
padding: 1px 8px;
border-radius: var(--raio-pill);
font-size: 11px;
font-weight: 600;
color: color-mix(in srgb, var(--cor) 70%, var(--texto));
background: color-mix(in srgb, var(--cor) 16%, transparent);
white-space: nowrap;
}
.texto {
overflow-wrap: anywhere;
}
.motivo {
display: block;
margin-top: 1px;
font-size: var(--t-xs);
color: var(--texto-3);
}
.o-vida { --cor: var(--destaque); }
.o-sentidos { --cor: var(--ok); }
.o-celular { --cor: #7aa2ff; }
.o-ia { --cor: #c084fc; }
.o-aprendizado { --cor: #fb923c; }
.o-ideias { --cor: var(--aviso); }
.o-sistema { --cor: var(--texto-3); }
</style>