primocode 10.15.2 → 10.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,6 @@ teste mede. O que toca hardware (escuta.py, hud.py, __main__.py) só chama isto.
6
6
  Maquina ouvindo → pensando → falando → (interrompido) → ouvindo
7
7
  Fatiador texto em streaming → frases prontas para falar
8
8
  resumo resposta longa → (fala curta, o que vai para o painel)
9
- ativacao "Primo, abre o projeto" → (True, "abre o projeto")
10
9
  comando "esconde o painel" → "esconder"
11
10
  confirmacao ferramenta → "rodando os testes"
12
11
  """
@@ -165,31 +164,6 @@ def resumo(texto: str, frases: int = 60, teto: int = 3000) -> Tuple[str, str]:
165
164
  return fala, painel
166
165
 
167
166
 
168
- # ── a palavra de ativação ────────────────────────────────────────────────
169
-
170
- _NOMES = ("primo", "primu", "prima", "primocode", "primo code")
171
- _ANTES = ("ei", "ey", "hey", "oi", "ola", "fala", "e ai", "opa", "salve")
172
-
173
-
174
- def ativacao(texto: str, nome: str = "Primo") -> Tuple[bool, str]:
175
- """Começa com "Primo" (ou "ei Primo")? Devolve (sim?, o resto do pedido).
176
-
177
- Só vale no INÍCIO da fala: "o primo da Ana ligou" não acorda ninguém."""
178
- cru = str(texto or "").strip()
179
- n = normalizar(cru)
180
- meus = _NOMES if normalizar(nome) in ("primo", "") else (normalizar(nome),)
181
- for ant in ("",) + _ANTES:
182
- for nm in meus:
183
- pre = (ant + " " + nm).strip()
184
- if n == pre or n.startswith(pre + " "):
185
- resto = re.sub(r"^\W*(?:" + r"\W+".join(re.escape(w) for w in pre.split()) + r")\W*", "",
186
- _sem_acento(cru), flags=re.I)
187
- # o corte vale no texto sem acento; o tamanho é o mesmo
188
- resto = cru[len(cru) - len(resto):] if len(resto) <= len(cru) else resto
189
- return True, resto.strip(" ,.!?;:-")
190
- return False, cru
191
-
192
-
193
167
  # ── comandos de voz que o painel e o modo resolvem sozinhos ──────────────
194
168
 
195
169
  _COMANDOS = [
@@ -144,6 +144,10 @@
144
144
  .leg span::before { content:""; display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; background:var(--marca); }
145
145
  .leg span.p::before { background:var(--ok); }
146
146
  .tecla { font-size:12px; color:var(--fraco); white-space:nowrap; }
147
+ .voltar-chat { display:inline-flex; align-items:center; gap:8px; height:38px; padding:0 16px; border:1px solid var(--borda); border-radius:999px; background:var(--fundo); color:var(--texto); font:500 13px var(--sans); cursor:pointer; white-space:nowrap; transition:border-color .2s; }
148
+ .voltar-chat:hover { border-color:var(--marca); }
149
+ .voltar-chat kbd { font:11px var(--mono); color:var(--fraco); }
150
+ .voltar-chat[hidden] { display:none; }
147
151
  </style>
148
152
  </head>
149
153
  <body>
@@ -270,6 +274,7 @@
270
274
  <div id="barras"></div>
271
275
  <div class="leg"><span>você</span><span class="p" id="leg-nome">Primo</span></div>
272
276
  <div class="tecla">⌨ luz do teclado no ritmo da voz</div>
277
+ <button class="voltar-chat" id="b-chat" hidden onclick="post({acao:'chat'})">← Voltar pro chat <kbd>esc</kbd></button>
273
278
  </section>
274
279
  </main>
275
280
 
@@ -529,7 +534,7 @@ function classeTela() {
529
534
  function setEstado(e) {
530
535
  if (!e || e === S.estado) return;
531
536
  S.estado = e; classeTela(); $('nome').dataset.e = e;
532
- $('estado-t').textContent = e === 'dormindo' ? 'diga "' + S.nome + '"' : (ROTULO[e] || e);
537
+ $('estado-t').textContent = e === 'dormindo' ? 'clique para falar' : (ROTULO[e] || e);
533
538
  R3.estado(e);
534
539
  S.olhar = (e === 'pensando' || e === 'executando') ? 1 : 0;
535
540
  $('eq').setAttribute('opacity', e === 'falando' ? 1 : 0);
@@ -557,8 +562,9 @@ function laco(t) { SP.quadro(t); if (!R3.ok && !SP.ok) animaEq(t); requestAnimat
557
562
  /* ── a ponte com o Python ── */
558
563
  window.primo = (m) => {
559
564
  if (m.pasta !== undefined) $('pasta').textContent = m.pasta;
565
+ if (m.comChat) $('b-chat').hidden = false;
560
566
  if (m.login !== undefined) $('b-entrar').hidden = !!m.login;
561
- if (m.nome) { S.nome = m.nome; $('nome-h').textContent = m.nome; $('leg-nome').textContent = m.nome; if (S.estado === 'dormindo') $('estado-t').textContent = 'diga "' + m.nome + '"'; }
567
+ if (m.nome) { S.nome = m.nome; $('nome-h').textContent = m.nome; $('leg-nome').textContent = m.nome; }
562
568
  if (m.robo === 'expressive' && !SP.ok) { if (SP.cancela) SP.cancela(); SP.falhou = true; classeTela(); }
563
569
  if (m.evento) R3.evento(m.evento);
564
570
  if (m.boot) montar();
@@ -30,8 +30,9 @@ from primo_voz.sons import Sons
30
30
  AQUI = Path(__file__).resolve().parent
31
31
 
32
32
 
33
- def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=None) -> int:
34
- """entrada/saida/ao_iniciar/ao_sair: o modo app (app.py) liga o painel ao motor de voz sem passar por stdin/stdout."""
33
+ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=None, chat=None) -> int:
34
+ """entrada/saida/ao_iniciar/ao_sair: o modo app (app.py) liga o painel ao motor de voz sem passar por stdin/stdout.
35
+ chat: o app.Chat; com ele a janela abre no chat.html e a voz (hud.html) fica atrás, trocada pelo botão de onda."""
35
36
  try:
36
37
  from AppKit import (NSApplication, NSApplicationActivationPolicyRegular, NSImage, NSMenu, NSMenuItem, NSBackingStoreBuffered, NSColor,
37
38
  NSEvent, NSScreen, NSWindow)
@@ -56,6 +57,40 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
56
57
  def js(msg: dict) -> None:
57
58
  fila.put("window.primo(" + json.dumps(msg, ensure_ascii=False) + ")")
58
59
 
60
+ fila_chat: "queue.Queue[str]" = queue.Queue()
61
+ fila_ui: "queue.Queue" = queue.Queue() # o que mexe na janela, rodado na thread principal
62
+
63
+ def js_chat(msg: dict) -> None:
64
+ fila_chat.put("window.chat&&window.chat(" + json.dumps(msg, ensure_ascii=False) + ")")
65
+
66
+ telas: dict = {}
67
+
68
+ def mostrar(qual: str) -> None:
69
+ """Troca entre o chat e a voz na mesma janela, sem recarregar nenhuma das duas."""
70
+ if not telas:
71
+ return
72
+ telas["chat"].setHidden_(qual != "chat")
73
+ telas["hud"].setHidden_(qual != "hud")
74
+ telas["atual"] = qual
75
+ telas["jan"].makeFirstResponder_(telas[qual])
76
+ if qual == "hud":
77
+ js({"comChat": True})
78
+ js({"boot": True})
79
+ if chat is not None:
80
+ threading.Thread(target=chat.app.ligar_voz, daemon=True).start()
81
+ elif chat is not None: # voltou pro chat: o microfone fecha
82
+ threading.Thread(target=chat.app.desligar_voz, daemon=True).start()
83
+
84
+ def painel_arquivos(pastas: bool):
85
+ from AppKit import NSOpenPanel
86
+ pn_ = NSOpenPanel.openPanel()
87
+ pn_.setCanChooseFiles_(not pastas)
88
+ pn_.setCanChooseDirectories_(pastas)
89
+ pn_.setAllowsMultipleSelection_(not pastas)
90
+ if pn_.runModal() != 1:
91
+ return []
92
+ return [str(u.path()) for u in pn_.URLs()]
93
+
59
94
  class Janela(NSWindow):
60
95
  def canBecomeKeyWindow(self):
61
96
  return True
@@ -73,6 +108,19 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
73
108
  break
74
109
  self.web.evaluateJavaScript_completionHandler_(cod, None)
75
110
  n += 1
111
+ while telas:
112
+ try:
113
+ cod = fila_chat.get_nowait()
114
+ except queue.Empty:
115
+ break
116
+ telas["chat"].evaluateJavaScript_completionHandler_(cod, None)
117
+ while True:
118
+ try:
119
+ fila_ui.get_nowait()()
120
+ except queue.Empty:
121
+ break
122
+ except Exception as e:
123
+ sys.stderr.write(f"hud: {e}\n")
76
124
  if self.jan.isMiniaturized():
77
125
  return
78
126
  if p.visivel != bool(self.jan.isVisible()):
@@ -124,6 +172,8 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
124
172
  sys.stderr.flush()
125
173
  elif a == "sair":
126
174
  p.visivel = False
175
+ elif a == "chat": # "Voltar pro chat", na tela de voz
176
+ mostrar("chat")
127
177
  elif a in ("ptt", "entrar"):
128
178
  emitir({"acao": a})
129
179
  elif a == "print": # esconde o HUD para o print pegar a tela de verdade
@@ -140,6 +190,52 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
140
190
  js({"musica": td.musica()})
141
191
  threading.Thread(target=_fazer, daemon=True).start()
142
192
 
193
+ class MsgChat(NSObject, protocols=[objc.protocolNamed("WKScriptMessageHandler")]):
194
+ """A ponte do chat.html. O que mexe na janela (trocar de tela, painel de arquivos, abrir link) é daqui; o resto vai para o app.Chat."""
195
+ def userContentController_didReceiveScriptMessage_(self, ucc, msg):
196
+ try:
197
+ m = json.loads(str(msg.body()))
198
+ except ValueError:
199
+ return
200
+ a = m.get("acao")
201
+ if a == "erro":
202
+ sys.stderr.write("chat.html: " + str(m.get("arg")) + "\n")
203
+ sys.stderr.flush()
204
+ elif a == "voz": # botão de onda: abre a voz e já começa a ouvir
205
+ mostrar("hud")
206
+ emitir({"acao": "ptt"})
207
+ elif a == "anexar":
208
+ caminhos = painel_arquivos(False)
209
+ if caminhos:
210
+ threading.Thread(target=chat.anexar_caminhos, args=(caminhos,), daemon=True).start()
211
+ elif a == "escolher_pasta":
212
+ caminhos = painel_arquivos(True)
213
+ if caminhos:
214
+ threading.Thread(target=chat.escolher_pasta, args=(caminhos[0],), daemon=True).start()
215
+ elif a == "abrir_link":
216
+ from AppKit import NSWorkspace
217
+ u = str(m.get("url") or "")
218
+ if u.startswith(("http://", "https://")):
219
+ NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(u))
220
+ elif a == "haptico": # a travadinha do esforço: o trackpad dá um toque em cada nível
221
+ try:
222
+ from AppKit import NSHapticFeedbackManager
223
+ padrao = 2 if m.get("forte") else 1 # 1 = alinhamento (leve), 2 = troca de nível (mais firme)
224
+ NSHapticFeedbackManager.defaultPerformer().performFeedbackPattern_performanceTime_(padrao, 1)
225
+ except Exception as e:
226
+ sys.stderr.write(f"hud: háptico: {e}\n")
227
+ elif a in ("abrir_arquivo", "mostrar_finder"): # só o que existe; o Mac abre com o app padrão
228
+ from AppKit import NSWorkspace
229
+ c = Path(os.path.expanduser(str(m.get("caminho") or "")))
230
+ if c.is_absolute() and c.exists():
231
+ ws = NSWorkspace.sharedWorkspace()
232
+ if a == "abrir_arquivo":
233
+ ws.openURL_(NSURL.fileURLWithPath_(str(c)))
234
+ else:
235
+ ws.activateFileViewerSelectingURLs_([NSURL.fileURLWithPath_(str(c))])
236
+ else:
237
+ chat.receber(m)
238
+
143
239
  app = NSApplication.sharedApplication()
144
240
  app.setActivationPolicy_(NSApplicationActivationPolicyRegular) # app de verdade: Dock e Cmd+Tab
145
241
  try: # nome e ícone do Primo, mesmo fora do bundle
@@ -185,21 +281,49 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
185
281
  web = WKWebView.alloc().initWithFrame_configuration_(NSMakeRect(0, 0, tela.size.width, tela.size.height), cfg)
186
282
  web.setAutoresizingMask_(2 | 16)
187
283
  web.setValue_forKey_(False, "drawsBackground")
188
- jan.setContentView_(web)
189
284
  url = NSURL.fileURLWithPath_(str(AQUI / "hud.html"))
190
285
  web.loadFileURL_allowingReadAccessToURL_(url, NSURL.fileURLWithPath_(str(AQUI)))
286
+ if chat is None:
287
+ jan.setContentView_(web)
288
+ else: # o chat na frente; a voz atrás, já carregada (troca sem esperar)
289
+ from AppKit import NSView
290
+ fundo = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, tela.size.width, tela.size.height))
291
+ fundo.setAutoresizingMask_(2 | 16)
292
+ cfg_c = WKWebViewConfiguration.alloc().init()
293
+ cfg_c.preferences().setValue_forKey_(True, "allowFileAccessFromFileURLs") # o vídeo/imagem/PDF citados tocam ali mesmo
294
+ cfg_c.userContentController().addScriptMessageHandler_name_(MsgChat.alloc().init(), "chat")
295
+ web_c = WKWebView.alloc().initWithFrame_configuration_(NSMakeRect(0, 0, tela.size.width, tela.size.height), cfg_c)
296
+ web_c.setAutoresizingMask_(2 | 16)
297
+ web_c.setValue_forKey_(False, "drawsBackground")
298
+ # leitura da pasta pessoal: os arquivos que o agente cita (~/Primo/…) aparecem no chat sem servidor
299
+ web_c.loadFileURL_allowingReadAccessToURL_(NSURL.fileURLWithPath_(str(AQUI / "chat.html")), NSURL.fileURLWithPath_(str(Path.home())))
300
+ fundo.addSubview_(web)
301
+ fundo.addSubview_(web_c)
302
+ jan.setContentView_(fundo)
303
+ telas.update({"chat": web_c, "hud": web, "jan": jan, "atual": "chat"})
304
+ web.setHidden_(True)
305
+ chat.js = js_chat
306
+ chat.mostrar_voz = lambda: fila_ui.put(lambda: mostrar("hud")) # um pedido falado com o chat na frente
191
307
  jan.makeKeyAndOrderFront_(None)
192
308
  app.activateIgnoringOtherApps_(True)
193
309
  alvo = Alvo.alloc().init()
194
310
  alvo.jan, alvo.web = jan, web
195
311
  NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(1 / 30, alvo, b"tick:", None, True)
196
- threading.Timer(1.2, lambda: (js({"nome": identidade.ler()["nome"]}), js({"robo": identidade.ler()["robo"]}), js({"boot": True}), sons.tocar("boot"))).start()
312
+ if chat is None: # sem chat a voz é a tela: o robô acorda com o som
313
+ threading.Timer(1.2, lambda: (js({"nome": identidade.ler()["nome"]}), js({"robo": identidade.ler()["robo"]}), js({"boot": True}), sons.tocar("boot"))).start()
314
+ else: # com chat: só o nome e o robô, em silêncio; o boot fica pra quando abrir a voz
315
+ threading.Timer(1.2, lambda: (js({"nome": identidade.ler()["nome"]}), js({"robo": identidade.ler()["robo"]}))).start()
197
316
 
198
317
  def esconder():
199
318
  p.visivel = False
200
319
 
201
320
  def tecla(ev):
202
- if ev.keyCode() == 53: # ESC
321
+ if ev.keyCode() == 53: # ESC: na voz com chat, volta pro chat; no chat, fica com a página
322
+ if telas:
323
+ if telas["atual"] == "hud":
324
+ mostrar("chat")
325
+ return None
326
+ return ev
203
327
  esconder()
204
328
  return None
205
329
  return ev
@@ -1,7 +1,7 @@
1
1
  """identidade.py — quem é o assistente e quem é a pessoa.
2
2
 
3
3
  O assistente se chama "Primo" até a pessoa dar outro nome ("seu nome agora é
4
- Luna", ou `primocode voz nome Luna`). A palavra de ativação é esse nome. Fica em
4
+ Luna", ou `primocode voz nome Luna`). O nome NÃO ativa nada: o modo voz só abre no clique. Fica em
5
5
  ~/.primocode/voz.json, junto com o nome da pessoa e as chaves de luz e som.
6
6
 
7
7
  Personalidade: curta, natural, humor leve. Cumprimenta pela hora, lembra o nome