feat(vida): ideias do dia com inspiração da internet e "olha o que eu aprendi"

Todo dia, a partir das 9h, o Carinha cria 3 atividades novas (inspiracao.py):
- sinais de fontes abertas: tempo da cidade (Open-Meteo), datas
  comemorativas da semana (lista fixa + feriados da BrasilAPI), "neste dia"
  da Wikipédia e assuntos em alta no Google Trends Brasil;
- o Gemini escolhe ideias leves (descarta tragédia, política e afins) e
  cada uma vira atividade pelo estúdio (Criacao.gerar), com motivo e uma
  curiosidade;
- até 3 tentativas por dia, 30 min entre elas; botão "Criar mais agora";
- as atividades criadas entram na rotina do modo vida (as de hoje com 40%
  de chance) e, com alguém perto, ele conta a curiosidade: legenda e voz
  de robô (ordem "aprendi", até 2x por dia cada);
- painel: cartão "Ideias do dia" no estúdio (Mostrar, Contar, o que ele
  viu na internet, ajustes: quantas, horário, cidade, fontes) e tópico no
  guia.

Correções achadas no caminho:
- WebView de fábrica do S10 (Chrome 102) não tem AbortSignal.timeout: o
  celular nunca mandava o status e a placa o via offline;
- arquivos estáticos com Cache-Control no-cache e o app com LOAD_NO_CACHE:
  o WebView seguia com uma carinha.html velha depois de deploy;
- status do celular recusado vai para o journal (1 linha por minuto);
- legenda mais estreita para não cobrir a bateria.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-08 08:13:03 -03:00
co-authored by Claude Opus 5.5
parent 43770c6d67
commit 4777ceb023
19 changed files with 1179 additions and 30 deletions
+262
View File
@@ -0,0 +1,262 @@
<script lang="ts">
import { Lightbulb, MessageCircle, Play, Settings2, Wand2 } from '@lucide/svelte';
import { onMount } from 'svelte';
import { criacao } from '../criacao.svelte';
import { inspiracao, type Fonte } from '../inspiracao.svelte';
import Card from './Card.svelte';
import Selo from './Selo.svelte';
import Toggle from './Toggle.svelte';
// Ideias do dia: o que ele criou hoje inspirado na internet, a curiosidade de cada uma e os ajustes.
onMount(() => inspiracao.carregar());
const st = $derived(inspiracao.status);
const cfg = $derived(st?.config);
let ajustesAbertos = $state(false);
let cidade = $state('');
const ICONES: Record<Fonte, string> = { tempo: '🌦️', datas: '📅', historia: '📜', alta: '📈' };
const HORAS = Array.from({ length: 23 }, (_, h) => h);
const vazio = $derived.by(() => {
if (!st || !cfg) return '';
if (!st.pode) return 'Precisa da chave Gemini (Ajustes › Inteligência artificial).';
if (!cfg.ligado) return 'Desligado: ligue para ele ter ideias novas todo dia.';
const h = new Date().getHours();
return h < cfg.hora ? `As ideias de hoje chegam a partir das ${cfg.hora}h.` : 'Já já: ele está procurando inspiração.';
});
const sinais = $derived(
st ? (Object.keys(st.fontes) as Fonte[]).filter((f) => st.sinais[f]).map((f) => ({ f, valor: st.sinais[f]! })) : [],
);
async function salvarCidade(e: SubmitEvent) {
e.preventDefault();
if (cidade.trim() && (await inspiracao.configurar({ cidade: cidade.trim() }, 'Cidade salva.'))) cidade = '';
}
function fonte(f: Fonte, v: boolean) {
if (cfg) inspiracao.configurar({ fontes: { ...cfg.fontes, [f]: v } });
}
</script>
<Card titulo="Ideias do dia" ajuda="ideias" descricao="Todo dia ele procura inspiração na internet e cria atividades novas, que entram na rotina dele.">
{#snippet acoes()}
<button class="btn pequeno" onclick={() => inspiracao.agora()} disabled={!st?.pode || st?.ocupado}>
<Wand2 size={14} /> {st?.ocupado ? 'Criando…' : 'Criar mais agora'}
</button>
{/snippet}
{#if !st || !cfg}
<p class="fraco">Carregando…</p>
{:else}
{#if st.ocupado}
<Selo tom="aviso">Procurando ideias na internet e criando as atividades…</Selo>
{:else if st.erro}
<p class="erro pequeno-texto">{st.erro}{st.tentativas ? ` (tentativa ${st.tentativas} de 3; tenta de novo em 30 min)` : ''}</p>
{/if}
{#if st.ideias.length}
<ul class="ideias">
{#each st.ideias as i (i.id)}
{@const contou = st.contadas[String(i.id)] ?? 0}
<li class="ideia">
<span class="emoji" aria-hidden="true">{i.emoji}</span>
<div class="texto">
<strong>{i.nome}</strong>
{#if i.motivo}<span class="motivo">{i.motivo}</span>{/if}
{#if i.aprendi}<p class="aprendi"><Lightbulb size={13} /> {i.aprendi}</p>{/if}
</div>
<div class="botoes">
<button class="btn pequeno" onclick={() => criacao.tocar(i.id)} title="Mostrar a atividade no celular"><Play size={14} /> Mostrar</button>
{#if i.aprendi}
<button class="btn pequeno fantasma" onclick={() => inspiracao.contar(i.id)} title="Ele conta a curiosidade agora">
<MessageCircle size={14} /> Contar{contou ? ` (${contou}×)` : ''}
</button>
{/if}
</div>
</li>
{/each}
</ul>
{:else}
<p class="fraco">{vazio}</p>
{/if}
{#if sinais.length}
<details class="dobra">
<summary>O que ele viu hoje na internet</summary>
<ul class="sinais">
{#each sinais as s (s.f)}
<li>
<span class="fonte">{ICONES[s.f]} {st.fontes[s.f]}</span>
<span class="valor">{Array.isArray(s.valor) ? s.valor.slice(0, 8).join(' · ') : s.valor}</span>
</li>
{/each}
</ul>
</details>
{/if}
<button class="btn fantasma pequeno ajustes" onclick={() => (ajustesAbertos = !ajustesAbertos)} aria-expanded={ajustesAbertos}>
<Settings2 size={14} /> Ajustes das ideias
</button>
{#if ajustesAbertos}
<div class="config">
<Toggle rotulo="Ter ideias novas todo dia" checked={cfg.ligado} onchange={(v) => inspiracao.configurar({ ligado: v })} />
<Toggle
rotulo="Olha o que eu aprendi hoje"
ajuda="Conta a curiosidade de cada ideia quando tem alguém perto"
checked={cfg.aprendi}
onchange={(v) => inspiracao.configurar({ aprendi: v })}
/>
<div class="linha-campos">
<label class="campo">
<span>Ideias por dia</span>
<select value={cfg.por_dia} onchange={(e) => inspiracao.configurar({ por_dia: Number(e.currentTarget.value) })}>
{#each [1, 2, 3, 4, 5] as n (n)}<option value={n}>{n}</option>{/each}
</select>
</label>
<label class="campo">
<span>A partir de</span>
<select value={cfg.hora} onchange={(e) => inspiracao.configurar({ hora: Number(e.currentTarget.value) })}>
{#each HORAS as h (h)}<option value={h}>{h}h</option>{/each}
</select>
</label>
</div>
<form class="campo" onsubmit={salvarCidade}>
<span>Cidade do tempo: <strong>{cfg.cidade.nome}</strong></span>
<div class="linha">
<input type="text" bind:value={cidade} placeholder="Trocar cidade (ex.: Curitiba)" maxlength="60" />
<button class="btn pequeno" type="submit" disabled={!cidade.trim()}>Salvar</button>
</div>
</form>
<fieldset class="campo">
<legend>De onde tirar inspiração</legend>
{#each Object.entries(st.fontes) as [f, nome] (f)}
<Toggle rotulo={`${ICONES[f as Fonte]} ${nome}`} checked={cfg.fontes[f as Fonte]} onchange={(v) => fonte(f as Fonte, v)} />
{/each}
</fieldset>
</div>
{/if}
{/if}
</Card>
<style>
.ideias {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
}
.ideia {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--e-3);
align-items: center;
padding: var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
}
@media (max-width: 640px) {
.ideia {
grid-template-columns: auto minmax(0, 1fr);
}
.botoes {
grid-column: 1 / -1;
}
}
.emoji {
font-size: 1.8rem;
line-height: 1;
}
.texto {
display: grid;
gap: 2px;
}
.motivo {
font-size: var(--t-xs);
color: var(--texto-3);
}
.aprendi {
margin: var(--e-1) 0 0;
font-size: var(--t-sm);
color: var(--texto-2);
font-style: italic;
}
.aprendi :global(svg) {
vertical-align: -2px;
color: var(--aviso);
}
.botoes {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
justify-content: flex-end;
}
.dobra {
margin-top: var(--e-3);
font-size: var(--t-sm);
}
.dobra summary {
cursor: pointer;
color: var(--texto-2);
}
.sinais {
list-style: none;
margin: var(--e-2) 0 0;
padding: 0;
display: grid;
gap: var(--e-2);
}
.sinais li {
display: grid;
gap: 2px;
}
.fonte {
font-weight: 600;
font-size: var(--t-xs);
}
.valor {
font-size: var(--t-xs);
color: var(--texto-3);
overflow-wrap: anywhere;
}
.ajustes {
margin-top: var(--e-3);
}
.config {
display: grid;
gap: var(--e-2);
margin-top: var(--e-2);
padding-top: var(--e-3);
border-top: 1px solid var(--borda);
}
.linha-campos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--e-3);
}
.campo {
display: grid;
gap: var(--e-1);
font-size: var(--t-sm);
}
fieldset.campo {
border: 0;
margin: 0;
padding: 0;
}
legend {
padding: 0;
margin-bottom: var(--e-1);
}
.linha {
display: flex;
gap: var(--e-2);
}
.linha input {
flex: 1;
min-width: 0;
}
.erro {
color: var(--perigo);
}
</style>
+3
View File
@@ -18,6 +18,9 @@ export interface ItemCriado {
receita: any;
ia: IaCriacao;
pedido: string;
origem?: 'pedido' | 'dia';
motivo?: string;
aprendi?: string;
}
export interface Provedor {
+3 -1
View File
@@ -117,6 +117,8 @@ const tratadores: Record<string, (d: any) => void> = {
expressao: (d: { ordem: string }) => {
// 'humor x' é o modo vida avisando o celular; aparece no cartão Vida, não no feed
if (d.ordem === 'calma' || /^(humor|ritmo|zanga|sono) /.test(d.ordem)) return;
// contar o que aprendeu já aparece no diário do cartão Vida
if (d.ordem.startsWith('aprendi ')) return;
const criada = /^(expressao|atividade)_criada (.*)$/.exec(d.ordem);
if (criada) {
let nome = '';
@@ -183,7 +185,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', 'criacao']) {
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'criacao', 'inspiracao']) {
fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados);
+16 -1
View File
@@ -196,6 +196,21 @@ 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).' },
],
},
ideias: {
titulo: 'Ideias do dia',
emoji: '💡',
onde: 'Criar com IA › Ideias do dia',
resumo:
'Todo dia, a partir do horário escolhido, ele procura inspiração na internet e o Gemini cria atividades novas (3 por padrão). A IA descarta tragédias, política e afins: só entram temas leves. As novidades entram na rotina do modo vida, as de hoje com mais chance, e ficam na grade do estúdio para tocar, favoritar ou apagar.',
itens: [
{ emoji: '🌦️', nome: 'Tempo da cidade', texto: 'Chuva, sol, frio e calor de hoje na cidade escolhida (Open-Meteo). Ex.: dia de chuva vira "lendo com chá".' },
{ emoji: '📅', nome: 'Datas comemorativas', texto: 'Feriados (BrasilAPI) e datas como Dia das Crianças, Dia da Pizza, Halloween e Natal, de hoje e dos próximos dias.' },
{ emoji: '📜', nome: 'Neste dia na história', texto: 'Fatos que aconteceram neste dia, da Wikipédia em português.' },
{ emoji: '📈', nome: 'Assuntos em alta', texto: 'O que o pessoal está procurando hoje no Google Brasil. Política e notícias tristes são ignoradas.' },
{ emoji: '🤓', nome: 'Olha o que eu aprendi hoje', texto: 'Cada ideia vem com uma curiosidade. Quando tem alguém perto, ele conta, com legenda e voz de robô (até 2 vezes por dia cada uma). O botão "Contar" faz ele contar na hora.' },
{ emoji: '💳', nome: 'Custo', texto: 'Uma chamada ao Gemini para escolher as ideias e uma por atividade: 4 por dia com o padrão de 3.' },
],
},
personalidade: {
titulo: 'IA local (Qwen)',
emoji: '🧠',
@@ -319,7 +334,7 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'aparencia',
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia',
'personalidade', 'acontecimentos', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos',
];
+78
View File
@@ -0,0 +1,78 @@
/**
* Ideias do dia (/criacao/dia): todo dia o Carinha cria atividades novas inspiradas no tempo, nas
* datas, no "neste dia" da Wikipédia e no que está em alta, e conta o que aprendeu com cada uma.
* O estado chega por SSE ('inspiracao') e é recarregado ao abrir a tela.
*/
import { pedir, postar } from './api';
import { avisos, tentar } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export type Fonte = 'tempo' | 'datas' | 'historia' | 'alta';
export interface ConfigInspiracao {
ligado: boolean;
por_dia: number;
hora: number;
aprendi: boolean;
cidade: { nome: string; lat: number; lon: number };
fontes: Record<Fonte, boolean>;
}
export interface IdeiaDoDia {
id: number;
nome: string;
emoji: string;
motivo: string;
aprendi: string;
horario: number;
}
export interface StatusInspiracao {
config: ConfigInspiracao;
fontes: Record<Fonte, string>;
ocupado: boolean;
pode: boolean;
dia: string;
erro: string;
tentativas: number;
feito_em: number | null;
sinais: Partial<Record<Fonte, string | string[]>>;
contadas: Record<string, number>;
ideias: IdeiaDoDia[];
}
class Inspiracao {
status = $state<StatusInspiracao | null>(null);
async carregar() {
try {
this.status = await pedir<StatusInspiracao>('/criacao/dia');
} catch (e) {
avisos.erro(e);
}
}
async configurar(dados: Partial<Omit<ConfigInspiracao, 'cidade'>> & { cidade?: string }, aviso?: string) {
const r = await tentar(() => postar<StatusInspiracao>('/criacao/dia/config', dados), aviso);
if (r) this.status = r;
return !!r;
}
agora() {
return tentar(() => postar('/criacao/dia/agora', {}), 'Procurando ideias na internet…');
}
contar(id: number) {
return tentar(() => postar('/criacao/dia/contar', { id }));
}
}
export const inspiracao = new Inspiracao();
ouvir('inspiracao', (d: StatusInspiracao) => {
const antes = inspiracao.status;
inspiracao.status = d;
if (antes?.ocupado && !d.ocupado) {
if (d.erro) avisos.erro(new Error(d.erro));
else if (d.ideias.length > (antes.ideias?.length ?? 0)) avisos.ok(`Ideias novas: ${d.ideias.slice(antes.ideias.length).map((i) => i.emoji + ' ' + i.nome).join(', ')}`);
}
});
+4 -1
View File
@@ -3,6 +3,7 @@
import { onMount } from 'svelte';
import { favoritos } from '../lib/comandos.svelte';
import Card from '../lib/componentes/Card.svelte';
import IdeiasDoDia from '../lib/componentes/IdeiasDoDia.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import { criacao, TIPOS, type IaCriacao, type TipoCriacao } from '../lib/criacao.svelte';
@@ -153,6 +154,8 @@
<div class="previa"><RostoPrevia titulo="Prévia das criações" espelharSono={false} /></div>
</div>
<IdeiasDoDia />
<Card titulo="Criações" descricao="Toque para mostrar no celular. A estrela fixa expressões e atividades nos favoritos do Ao vivo.">
{#snippet acoes()}
<div class="segmentado pequeno" role="radiogroup" aria-label="Filtrar">
@@ -174,7 +177,7 @@
<button class="cartao" onclick={() => criacao.tocar(c.id)} title={c.pedido}>
<span class="emoji" aria-hidden="true">{c.emoji}</span>
<span class="nome">{c.nome}</span>
<span class="meta">{nomeTipo(c.tipo)} · {st.provedores[c.ia]?.nome ?? c.ia}</span>
<span class="meta">{c.origem === 'dia' ? `💡 ${c.motivo || 'Ideia do dia'}` : `${nomeTipo(c.tipo)} · ${st.provedores[c.ia]?.nome ?? c.ia}`}</span>
</button>
{#if podeFavoritar(c.tipo)}
<button