openTARS 5.3 Tesseract
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +7 -5
- app/empacotamento/conferir.sh +3 -2
- app/empacotamento/debian/control +11 -15
- app/empacotamento/debian/postinst +2 -0
- app/empacotamento/doc/changelog +7 -0
- app/empacotamento/linux/instalar.sh +14 -7
- app/empacotamento/macos/instalar.sh +7 -6
- app/empacotamento/setup.sh +16 -7
- app/empacotamento/windows/instalar.ps1 +9 -8
- app/empacotamento/windows/opentars.iss +44 -0
- app/empacotamento/winget/gerar.sh +57 -0
- app/idiomas/en.json +156 -35
- app/idiomas/pt_BR.json +156 -35
- app/nucleo/aplicacoes.py +38 -0
- app/nucleo/chat.py +28 -3
- app/nucleo/controle.py +11 -1
- app/nucleo/elementos.py +10 -0
- app/nucleo/gargantua.py +15 -8
- app/nucleo/hardware.py +5 -5
- app/nucleo/nuvem.py +1 -1
- app/nucleo/ollama.py +23 -4
- app/nucleo/registro.py +5 -3
- app/nucleo/selecao.py +2 -2
- app/nucleo/terminal.py +25 -27
- app/nucleo/visao.py +1 -1
- app/nucleo/visual.py +165 -0
- app/nucleo/voz.py +2 -2
- app/tars.py +19 -18
- app/tars_acessibilidade.py +51 -3
- app/tars_acoes_texto.py +11 -2
- app/tars_anexos.py +0 -43
- app/tars_assistente.py +2 -1
- app/tars_atalhos.py +316 -0
- app/tars_atalhos_textos.py +114 -0
- app/tars_atualizacao.py +44 -9
- app/tars_autoteste.py +1 -1
- app/tars_busca_arquivos.py +142 -1
- app/tars_confianca.py +5 -1
- app/tars_conta.py +58 -0
- app/tars_email.py +324 -0
- app/tars_gerenciar_modelos.py +4 -3
- app/tars_gui.py +0 -20
- app/tars_imagem.py +3 -2
- app/tars_integracoes.py +476 -0
- app/tars_lembretes.py +130 -0
- app/tars_mais_modelos.py +59 -8
- app/tars_midia.py +172 -0
- app/tars_modos.py +36 -25
- app/tars_motor.py +19 -0
- app/tars_motor_local.py +797 -0
README.md
CHANGED
|
@@ -20,7 +20,7 @@ This repository is a **mirror** of the app for people who find openTARS here. Th
|
|
| 20 |
|
| 21 |
## What is here
|
| 22 |
|
| 23 |
-
- `opentars_all.deb`: the Ubuntu/Debian package (version 5.
|
| 24 |
- `app/`: the application source and the installers (`app/empacotamento/`).
|
| 25 |
- `LICENSE`: MIT.
|
| 26 |
|
|
@@ -41,13 +41,15 @@ curl -fsSL https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/
|
|
| 41 |
Ubuntu, Debian and derivatives:
|
| 42 |
|
| 43 |
```bash
|
| 44 |
-
wget -O /tmp/opentars.deb https://github.com/project-opentars/OpenTARS/
|
| 45 |
```
|
| 46 |
|
| 47 |
-
The
|
| 48 |
|
| 49 |
## Models
|
| 50 |
|
| 51 |
-
- [Gargantua 2](https://huggingface.co/OpenTARS/Gargantua-2): the main model (
|
| 52 |
-
- [Gargantua
|
|
|
|
|
|
|
| 53 |
- [Murph](https://huggingface.co/OpenTARS/Murph): the tiny request classifier built into the app.
|
|
|
|
| 20 |
|
| 21 |
## What is here
|
| 22 |
|
| 23 |
+
- `opentars_all.deb`: the Ubuntu/Debian package (version 5.3 "Tesseract", revision 2026111301).
|
| 24 |
- `app/`: the application source and the installers (`app/empacotamento/`).
|
| 25 |
- `LICENSE`: MIT.
|
| 26 |
|
|
|
|
| 41 |
Ubuntu, Debian and derivatives:
|
| 42 |
|
| 43 |
```bash
|
| 44 |
+
wget -O /tmp/opentars.deb https://github.com/project-opentars/OpenTARS/releases/latest/download/opentars_all.deb && sudo apt install -y --reinstall /tmp/opentars.deb
|
| 45 |
```
|
| 46 |
|
| 47 |
+
The app downloads its engine (llama.cpp) and Gargantua 2.5 the first time it opens, each checked against a fixed SHA-256. The install commands above download and run code from GitHub; read the script first if you like. How to verify the package: https://project-opentars.github.io/OpenTARS/verify.html
|
| 48 |
|
| 49 |
## Models
|
| 50 |
|
| 51 |
+
- [Gargantua 2.5](https://huggingface.co/OpenTARS/Gargantua-2.5): the main model (Gemma 4 E4B, with vision and audio).
|
| 52 |
+
- [Gargantua Lite Gen 3](https://huggingface.co/OpenTARS/Gargantua-2.5-Lite): the smaller model (Gemma 4 E2B).
|
| 53 |
+
- [Gargantua Pro Gen 1](https://huggingface.co/OpenTARS/Gargantua-Pro): the largest model, for missions in a virtual machine (Gemma 4 12B).
|
| 54 |
+
- [Gargantua Code Gen 2](https://huggingface.co/OpenTARS/Gargantua-Code-Gen2): for programming.
|
| 55 |
- [Murph](https://huggingface.co/OpenTARS/Murph): the tiny request classifier built into the app.
|
app/empacotamento/conferir.sh
CHANGED
|
@@ -42,8 +42,9 @@ fi
|
|
| 42 |
|
| 43 |
echo "== 4/4 REVISAO"
|
| 44 |
nova=$(grep -oP '^REVISAO = \K\d+' tars.py)
|
| 45 |
-
|
| 46 |
-
|
|
|
|
| 47 |
antiga=$(dpkg-deb -f "$publicado" Version 2>/dev/null | grep -oP '\+\K\d+$' || echo 0)
|
| 48 |
if [ "${nova:-0}" -le "${antiga:-0}" ]; then
|
| 49 |
problemas+=("REVISAO $nova não é maior que a do opentars_all.deb da entrega ($antiga): suba em tars.py")
|
|
|
|
| 42 |
|
| 43 |
echo "== 4/4 REVISAO"
|
| 44 |
nova=$(grep -oP '^REVISAO = \K\d+' tars.py)
|
| 45 |
+
# desde 12/11 os pacotes ficam em pacotes/atual (o mais novo do app é a entrega a superar)
|
| 46 |
+
publicado=$(ls -t ../../pacotes/atual/opentars_*_all.deb ../../opentars_all.deb 2>/dev/null | head -1)
|
| 47 |
+
if [ -n "$publicado" ] && [ -f "$publicado" ]; then
|
| 48 |
antiga=$(dpkg-deb -f "$publicado" Version 2>/dev/null | grep -oP '\+\K\d+$' || echo 0)
|
| 49 |
if [ "${nova:-0}" -le "${antiga:-0}" ]; then
|
| 50 |
problemas+=("REVISAO $nova não é maior que a do opentars_all.deb da entrega ($antiga): suba em tars.py")
|
app/empacotamento/debian/control
CHANGED
|
@@ -12,19 +12,15 @@ Provides: tars
|
|
| 12 |
Installed-Size: @TAMANHO@
|
| 13 |
Maintainer: Enzo <enzorcasao@gmail.com>
|
| 14 |
Homepage: https://github.com/project-opentars/OpenTARS
|
| 15 |
-
Description:
|
| 16 |
-
openTARS is an AI assistant that runs
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
and runs terminal commands.
|
| 21 |
.
|
| 22 |
-
Its official AI is Gargantua (
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
without a graphical session. Files (images, text, PDF...) can be attached
|
| 29 |
-
to a request. The optional Cooper model (ollama pull ProjectOpenTARS/cooper)
|
| 30 |
-
helps it look at the screen.
|
|
|
|
| 12 |
Installed-Size: @TAMANHO@
|
| 13 |
Maintainer: Enzo <enzorcasao@gmail.com>
|
| 14 |
Homepage: https://github.com/project-opentars/OpenTARS
|
| 15 |
+
Description: local AI assistant for your desktop
|
| 16 |
+
openTARS is an AI assistant that runs on your own machine. It understands
|
| 17 |
+
requests in English and Portuguese and works on the desktop: opens and
|
| 18 |
+
closes apps, clicks buttons by name, types, searches the web, looks at the
|
| 19 |
+
screen and runs terminal commands, asking before anything risky.
|
|
|
|
| 20 |
.
|
| 21 |
+
Its official AI is Gargantua 2.5 (Gemma 4), trained to use the openTARS
|
| 22 |
+
tools, which sees images and understands speech. It runs on the llama.cpp
|
| 23 |
+
engine, downloaded with the models the first time the app opens. Comes with
|
| 24 |
+
a window ("opentars-gui"), a quick bar opened by a global shortcut, text
|
| 25 |
+
actions in any app and a terminal mode ("opentars --chat"). Local by
|
| 26 |
+
default; cloud models are optional and use your own key.
|
|
|
|
|
|
|
|
|
app/empacotamento/debian/postinst
CHANGED
|
@@ -19,6 +19,8 @@ case "$1" in
|
|
| 19 |
fi
|
| 20 |
|
| 21 |
bash /usr/share/opentars/setup.sh || true
|
|
|
|
|
|
|
| 22 |
|
| 23 |
if command -v update-desktop-database >/dev/null 2>&1; then
|
| 24 |
update-desktop-database -q /usr/share/applications || true
|
|
|
|
| 19 |
fi
|
| 20 |
|
| 21 |
bash /usr/share/opentars/setup.sh || true
|
| 22 |
+
# sem isto cada abertura recompila tudo: quem usa o app não escreve em /usr/share (-12% ao abrir, medido em 06/11)
|
| 23 |
+
/usr/share/opentars/venv/bin/python3 -m compileall -q -x /venv/ /usr/share/opentars || true
|
| 24 |
|
| 25 |
if command -v update-desktop-database >/dev/null 2>&1; then
|
| 26 |
update-desktop-database -q /usr/share/applications || true
|
app/empacotamento/doc/changelog
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
opentars (5.2) unstable; urgency=medium
|
| 2 |
|
| 3 |
5.2 Ranger
|
|
|
|
| 1 |
+
opentars (5.3) unstable; urgency=medium
|
| 2 |
+
|
| 3 |
+
5.3 Tesseract
|
| 4 |
+
* Em preparo (ainda não publicado): a API do motor mora em tars_motor.py, para a troca do Ollama
|
| 5 |
+
pelo llama.cpp ter um lugar só; Gargantua Pro e Pi reconhecidos como variantes, com o Pi fora
|
| 6 |
+
da conversa do app grande (ele tem contrato próprio); Gargantua Code Gen 2 em "Mais modelos".
|
| 7 |
+
|
| 8 |
opentars (5.2) unstable; urgency=medium
|
| 9 |
|
| 10 |
5.2 Ranger
|
app/empacotamento/linux/instalar.sh
CHANGED
|
@@ -6,9 +6,8 @@
|
|
| 6 |
#
|
| 7 |
# O que faz: instala os pacotes do sistema que o openTARS usa pelo gerenciador da distribuição (dnf, pacman, zypper ou
|
| 8 |
# apt; é a única parte que pede a senha), baixa o código do app (pasta app/ do repositório) para a pasta do usuário, cria
|
| 9 |
-
# um ambiente Python com o Python do sistema (que enxerga o PyGObject e o tkinter da distribuição),
|
| 10 |
-
#
|
| 11 |
-
# (Gargantua 2) é baixado na primeira abertura, com barra. Rodar de novo atualiza. Remover: "| bash -s -- --remover".
|
| 12 |
# Suas conversas e configurações (em ~/.config/opentars e ~/.local/share/opentars) ficam; apague as pastas se quiser.
|
| 13 |
set -euo pipefail
|
| 14 |
|
|
@@ -22,7 +21,7 @@ ICONE="$DADOS/icons/hicolor/scalable/apps/opentars.svg"
|
|
| 22 |
INICIO="${XDG_CONFIG_HOME:-$HOME/.config}/autostart"
|
| 23 |
ZIP="https://github.com/project-opentars/OpenTARS/archive/refs/heads/main.zip"
|
| 24 |
FONTE="" # --fonte PASTA: a pasta app/ local em vez de baixar (o CI usa)
|
| 25 |
-
SEM_OLLAMA=
|
| 26 |
SEM_SISTEMA=0 # --sem-sistema: não instala pacotes do sistema (quem já instalou à mão, ou sem sudo)
|
| 27 |
REMOVER=0
|
| 28 |
while [ $# -gt 0 ]; do
|
|
@@ -30,6 +29,7 @@ while [ $# -gt 0 ]; do
|
|
| 30 |
--remover) REMOVER=1 ;;
|
| 31 |
--fonte) FONTE="$2"; shift ;;
|
| 32 |
--sem-ollama) SEM_OLLAMA=1 ;;
|
|
|
|
| 33 |
--sem-sistema) SEM_SISTEMA=1 ;;
|
| 34 |
esac
|
| 35 |
shift
|
|
@@ -129,7 +129,14 @@ if ! "$VENV/bin/python3" -c "import sys" >/dev/null 2>&1; then
|
|
| 129 |
exit 1; }
|
| 130 |
fi
|
| 131 |
"$VENV/bin/python3" -m pip install --quiet --upgrade pip
|
| 132 |
-
"$VENV/bin/python3" -m pip install --quiet requests psutil pyautogui pillow PySide6-Essentials numpy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
# 4. O Ollama, pelo instalador oficial (ele mesmo pede a senha). Sem ele o openTARS abre, mas não tem IA local.
|
| 135 |
if [ "$SEM_OLLAMA" = 0 ]; then
|
|
@@ -165,8 +172,8 @@ sed "s|/usr/bin/opentars-gui|\"$BIN/opentars-gui\"|" "$TEMP/desktop/opentars.des
|
|
| 165 |
cp "$APP/empacotamento/icone/opentars.svg" "$ICONE"
|
| 166 |
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database -q "$(dirname "$ATALHO")" || true
|
| 167 |
|
| 168 |
-
dizer "Pronto. Abra o openTARS pelo menu de aplicativos (na primeira vez ele baixa o Gargantua 2, com barra)." \
|
| 169 |
-
"Done. Open openTARS from the application menu (the first time it downloads Gargantua 2, with a progress bar)."
|
| 170 |
case ":$PATH:" in *":$BIN:"*) ;; *) dizer "Os comandos ficaram em $BIN; para usar no terminal: export PATH=\"$BIN:\$PATH\"" \
|
| 171 |
"The commands are in $BIN; to use them in a terminal: export PATH=\"$BIN:\$PATH\"" ;; esac
|
| 172 |
dizer "Para conferir e medir: 'opentars --diagnostico' e 'opentars --prova'; mande os relatórios que eles salvam." \
|
|
|
|
| 6 |
#
|
| 7 |
# O que faz: instala os pacotes do sistema que o openTARS usa pelo gerenciador da distribuição (dnf, pacman, zypper ou
|
| 8 |
# apt; é a única parte que pede a senha), baixa o código do app (pasta app/ do repositório) para a pasta do usuário, cria
|
| 9 |
+
# um ambiente Python com o Python do sistema (que enxerga o PyGObject e o tkinter da distribuição), e cria os comandos opentars e opentars-gui em ~/.local/bin e o atalho no menu. O motor llama.cpp e o
|
| 10 |
+
# Gargantua 2.5 vêm na primeira abertura (--com-ollama instala o Ollama também). Rodar de novo atualiza. Remover: "| bash -s -- --remover".
|
|
|
|
| 11 |
# Suas conversas e configurações (em ~/.config/opentars e ~/.local/share/opentars) ficam; apague as pastas se quiser.
|
| 12 |
set -euo pipefail
|
| 13 |
|
|
|
|
| 21 |
INICIO="${XDG_CONFIG_HOME:-$HOME/.config}/autostart"
|
| 22 |
ZIP="https://github.com/project-opentars/OpenTARS/archive/refs/heads/main.zip"
|
| 23 |
FONTE="" # --fonte PASTA: a pasta app/ local em vez de baixar (o CI usa)
|
| 24 |
+
SEM_OLLAMA=1 # 5.3: o motor é o llama.cpp (o app baixa); --com-ollama instala o Ollama também; --sem-ollama fica aceito
|
| 25 |
SEM_SISTEMA=0 # --sem-sistema: não instala pacotes do sistema (quem já instalou à mão, ou sem sudo)
|
| 26 |
REMOVER=0
|
| 27 |
while [ $# -gt 0 ]; do
|
|
|
|
| 29 |
--remover) REMOVER=1 ;;
|
| 30 |
--fonte) FONTE="$2"; shift ;;
|
| 31 |
--sem-ollama) SEM_OLLAMA=1 ;;
|
| 32 |
+
--com-ollama) SEM_OLLAMA=0 ;;
|
| 33 |
--sem-sistema) SEM_SISTEMA=1 ;;
|
| 34 |
esac
|
| 35 |
shift
|
|
|
|
| 129 |
exit 1; }
|
| 130 |
fi
|
| 131 |
"$VENV/bin/python3" -m pip install --quiet --upgrade pip
|
| 132 |
+
"$VENV/bin/python3" -m pip install --quiet requests psutil pyautogui pillow PySide6-Essentials numpy
|
| 133 |
+
# 07/11: o Fedora passou ao Python 3.15 e o onnxruntime ainda não tem pacote para ele; sem ele o app roda, só as
|
| 134 |
+
# edições de foto com modelinho (tirar o fundo, aumentar, remover objeto) ficam de fora
|
| 135 |
+
"$VENV/bin/python3" -m pip install --quiet onnxruntime || dizer "Aviso: o onnxruntime não instala neste Python; tirar o fundo e as outras edições com modelinho ficam desligadas." \
|
| 136 |
+
"Warning: onnxruntime does not install on this Python; background removal and the other model-based photo edits are off."
|
| 137 |
+
# 08/11 (5.3): o cofre de senhas para o e-mail do assistente; sem ele o app roda, só o e-mail fica desligado
|
| 138 |
+
"$VENV/bin/python3" -m pip install --quiet keyring || dizer "Aviso: o keyring não instala neste Python; o e-mail do assistente fica desligado." \
|
| 139 |
+
"Warning: keyring does not install on this Python; the assistant's email stays off."
|
| 140 |
|
| 141 |
# 4. O Ollama, pelo instalador oficial (ele mesmo pede a senha). Sem ele o openTARS abre, mas não tem IA local.
|
| 142 |
if [ "$SEM_OLLAMA" = 0 ]; then
|
|
|
|
| 172 |
cp "$APP/empacotamento/icone/opentars.svg" "$ICONE"
|
| 173 |
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database -q "$(dirname "$ATALHO")" || true
|
| 174 |
|
| 175 |
+
dizer "Pronto. Abra o openTARS pelo menu de aplicativos (na primeira vez ele baixa o Gargantua 2.5, com barra)." \
|
| 176 |
+
"Done. Open openTARS from the application menu (the first time it downloads Gargantua 2.5, with a progress bar)."
|
| 177 |
case ":$PATH:" in *":$BIN:"*) ;; *) dizer "Os comandos ficaram em $BIN; para usar no terminal: export PATH=\"$BIN:\$PATH\"" \
|
| 178 |
"The commands are in $BIN; to use them in a terminal: export PATH=\"$BIN:\$PATH\"" ;; esac
|
| 179 |
dizer "Para conferir e medir: 'opentars --diagnostico' e 'opentars --prova'; mande os relatórios que eles salvam." \
|
app/empacotamento/macos/instalar.sh
CHANGED
|
@@ -4,9 +4,9 @@
|
|
| 4 |
# curl -fsSL https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/macos/instalar.sh | bash
|
| 5 |
#
|
| 6 |
# O que faz: baixa o código do app (pasta app/ do repositório), instala o uv (traz o Python), cria um ambiente Python com
|
| 7 |
-
# as dependências,
|
| 8 |
# o assina ad-hoc (grátis, sem conta da Apple; um app montado no próprio Mac não ganha a marca de quarentena), e cria os
|
| 9 |
-
# comandos opentars e opentars-gui em ~/.local/bin. O
|
| 10 |
# Rodar de novo atualiza. Remover: o mesmo comando com "| bash -s -- --remover".
|
| 11 |
# Suas conversas e configurações (em ~/Library/Application Support/opentars) ficam; apague a pasta se quiser.
|
| 12 |
set -euo pipefail
|
|
@@ -18,13 +18,14 @@ BIN="$HOME/.local/bin"
|
|
| 18 |
APP_MAC="$HOME/Applications/openTARS.app"
|
| 19 |
ZIP="https://github.com/project-opentars/OpenTARS/archive/refs/heads/main.zip"
|
| 20 |
FONTE="" # --fonte PASTA: a pasta app/ local em vez de baixar (o CI usa)
|
| 21 |
-
SEM_OLLAMA=
|
| 22 |
REMOVER=0
|
| 23 |
while [ $# -gt 0 ]; do
|
| 24 |
case "$1" in
|
| 25 |
--remover) REMOVER=1 ;;
|
| 26 |
--fonte) FONTE="$2"; shift ;;
|
| 27 |
--sem-ollama) SEM_OLLAMA=1 ;;
|
|
|
|
| 28 |
esac
|
| 29 |
shift
|
| 30 |
done
|
|
@@ -73,7 +74,7 @@ fi
|
|
| 73 |
dizer "Preparando o Python e as dependências (alguns minutos na primeira vez)..." \
|
| 74 |
"Setting up Python and the dependencies (a few minutes the first time)..."
|
| 75 |
[ -x "$VENV/bin/python3" ] || "$UV" venv --python 3.12 "$VENV"
|
| 76 |
-
"$UV" pip install --python "$VENV/bin/python3" requests psutil pyautogui pillow PySide6-Essentials numpy onnxruntime pyobjc-framework-ApplicationServices pip
|
| 77 |
|
| 78 |
# 3. O Ollama (o app oficial). Sem ele o openTARS abre, mas não tem IA local.
|
| 79 |
if [ "$SEM_OLLAMA" = 0 ]; then
|
|
@@ -118,8 +119,8 @@ printf '#!/bin/bash\nexec "%s/bin/python3" "%s/tars.py" "$@"\n' "$VENV" "$APP_CO
|
|
| 118 |
printf '#!/bin/bash\ncd "%s" && exec "%s/bin/python3" -m tars_qt "$@"\n' "$APP_CODIGO" "$VENV" > "$BIN/opentars-gui"
|
| 119 |
chmod +x "$BIN/opentars" "$BIN/opentars-gui"
|
| 120 |
|
| 121 |
-
dizer "Pronto. Abra o openTARS em ~/Applications (na primeira vez ele baixa o Gargantua 2, com barra)." \
|
| 122 |
-
"Done. Open openTARS from ~/Applications (the first time it downloads Gargantua 2, with a progress bar)."
|
| 123 |
dizer "O macOS vai pedir Acessibilidade e Gravação de Tela para o openTARS clicar e ver a tela: libere em Ajustes do Sistema > Privacidade e Segurança." \
|
| 124 |
"macOS will ask for Accessibility and Screen Recording so openTARS can click and see the screen: allow it in System Settings > Privacy & Security."
|
| 125 |
# o Terminal do macOS (zsh) não tem ~/.local/bin no PATH: sem isto, "opentars" dá "command not found". Uma linha marcada,
|
|
|
|
| 4 |
# curl -fsSL https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/macos/instalar.sh | bash
|
| 5 |
#
|
| 6 |
# O que faz: baixa o código do app (pasta app/ do repositório), instala o uv (traz o Python), cria um ambiente Python com
|
| 7 |
+
# as dependências, monta o openTARS.app em ~/Applications e
|
| 8 |
# o assina ad-hoc (grátis, sem conta da Apple; um app montado no próprio Mac não ganha a marca de quarentena), e cria os
|
| 9 |
+
# comandos opentars e opentars-gui em ~/.local/bin. O motor llama.cpp e o Gargantua 2.5 vêm na primeira abertura.
|
| 10 |
# Rodar de novo atualiza. Remover: o mesmo comando com "| bash -s -- --remover".
|
| 11 |
# Suas conversas e configurações (em ~/Library/Application Support/opentars) ficam; apague a pasta se quiser.
|
| 12 |
set -euo pipefail
|
|
|
|
| 18 |
APP_MAC="$HOME/Applications/openTARS.app"
|
| 19 |
ZIP="https://github.com/project-opentars/OpenTARS/archive/refs/heads/main.zip"
|
| 20 |
FONTE="" # --fonte PASTA: a pasta app/ local em vez de baixar (o CI usa)
|
| 21 |
+
SEM_OLLAMA=1 # 5.3: o motor é o llama.cpp (o app baixa); --com-ollama instala o Ollama também; --sem-ollama fica aceito
|
| 22 |
REMOVER=0
|
| 23 |
while [ $# -gt 0 ]; do
|
| 24 |
case "$1" in
|
| 25 |
--remover) REMOVER=1 ;;
|
| 26 |
--fonte) FONTE="$2"; shift ;;
|
| 27 |
--sem-ollama) SEM_OLLAMA=1 ;;
|
| 28 |
+
--com-ollama) SEM_OLLAMA=0 ;;
|
| 29 |
esac
|
| 30 |
shift
|
| 31 |
done
|
|
|
|
| 74 |
dizer "Preparando o Python e as dependências (alguns minutos na primeira vez)..." \
|
| 75 |
"Setting up Python and the dependencies (a few minutes the first time)..."
|
| 76 |
[ -x "$VENV/bin/python3" ] || "$UV" venv --python 3.12 "$VENV"
|
| 77 |
+
"$UV" pip install --python "$VENV/bin/python3" requests psutil pyautogui pillow PySide6-Essentials numpy onnxruntime keyring pyobjc-framework-ApplicationServices pip
|
| 78 |
|
| 79 |
# 3. O Ollama (o app oficial). Sem ele o openTARS abre, mas não tem IA local.
|
| 80 |
if [ "$SEM_OLLAMA" = 0 ]; then
|
|
|
|
| 119 |
printf '#!/bin/bash\ncd "%s" && exec "%s/bin/python3" -m tars_qt "$@"\n' "$APP_CODIGO" "$VENV" > "$BIN/opentars-gui"
|
| 120 |
chmod +x "$BIN/opentars" "$BIN/opentars-gui"
|
| 121 |
|
| 122 |
+
dizer "Pronto. Abra o openTARS em ~/Applications (na primeira vez ele baixa o Gargantua 2.5, com barra)." \
|
| 123 |
+
"Done. Open openTARS from ~/Applications (the first time it downloads Gargantua 2.5, with a progress bar)."
|
| 124 |
dizer "O macOS vai pedir Acessibilidade e Gravação de Tela para o openTARS clicar e ver a tela: libere em Ajustes do Sistema > Privacidade e Segurança." \
|
| 125 |
"macOS will ask for Accessibility and Screen Recording so openTARS can click and see the screen: allow it in System Settings > Privacy & Security."
|
| 126 |
# o Terminal do macOS (zsh) não tem ~/.local/bin no PATH: sem isto, "opentars" dá "command not found". Uma linha marcada,
|
app/empacotamento/setup.sh
CHANGED
|
@@ -20,7 +20,7 @@ MODELO_AJUDANTE="qwen2.5:0.5b"
|
|
| 20 |
MODELO_AJUDANTE_ANTIGO="gemma3:270m"
|
| 21 |
DIR_OPENTARS="/usr/share/opentars"
|
| 22 |
VENV="$DIR_OPENTARS/venv"
|
| 23 |
-
DEPENDENCIAS=("requests:requests" "psutil:psutil" "pyautogui:pyautogui" "PIL:pillow" "PySide6:PySide6-Essentials" "numpy:numpy" "onnxruntime:onnxruntime")
|
| 24 |
|
| 25 |
# Textos no idioma do sistema: vêm de idiomas/*.json, pelo mesmo módulo
|
| 26 |
# que o openTARS usa (tars_i18n.py). Se algo falhar, aparecem as chaves
|
|
@@ -100,7 +100,15 @@ modelos_instalados() {
|
|
| 100 |
# em Docker, via Snap...) conta como instalado: instalar outro por cima
|
| 101 |
# criaria um segundo serviço brigando pela mesma porta.
|
| 102 |
# ------------------------------------------------------------------
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
msg "$(t ollama_ja)"
|
| 105 |
elif ollama_responde; then
|
| 106 |
msg "$(t ollama_existente url="$OLLAMA_URL")"
|
|
@@ -222,12 +230,13 @@ servico_pronto=0
|
|
| 222 |
# Recém-instalado, o serviço leva um instante pra subir. Se o Ollama
|
| 223 |
# existe mas o serviço está parado (sem systemd, por exemplo), tenta
|
| 224 |
# iniciar.
|
| 225 |
-
if ! ollama_responde && command -v systemctl >/dev/null 2>&1 \
|
| 226 |
&& systemctl list-unit-files ollama.service >/dev/null 2>&1; then
|
| 227 |
systemctl start ollama >/dev/null 2>&1 || true
|
| 228 |
fi
|
| 229 |
|
| 230 |
for _ in $(seq 1 10); do
|
|
|
|
| 231 |
if ollama_responde; then
|
| 232 |
servico_pronto=1
|
| 233 |
break
|
|
@@ -238,7 +247,7 @@ done
|
|
| 238 |
# 3.0.5: nenhum modelo ajudante é baixado. Quem decide o tipo de cada
|
| 239 |
# pedido é a helper Murph, que já vem no pacote (modelos/murph.json).
|
| 240 |
msg "$(t helper_murph versao="1.0")"
|
| 241 |
-
if [ "$servico_pronto" -eq 0 ]; then
|
| 242 |
aviso "$(t ollama_nao_respondeu url="$OLLAMA_URL")"
|
| 243 |
# 4.5: WSL (e contêiner) costuma não ter systemd: o serviço não sobe sozinho
|
| 244 |
if em_wsl || ! command -v systemctl >/dev/null 2>&1; then
|
|
@@ -253,10 +262,10 @@ fi
|
|
| 253 |
# Com ele instalado, o passo 4 não baixa outro modelo de conversa.
|
| 254 |
# TARS_SEM_GARGANTUA=1 pula; TARS_MODELO_GARGANTUA=<nome> troca.
|
| 255 |
# ------------------------------------------------------------------
|
| 256 |
-
MODELO_GARGANTUA="${TARS_MODELO_GARGANTUA:-ProjectOpenTARS/Gargantua-2}"
|
| 257 |
|
| 258 |
# 5.1: máquina fraca (sem placa NVIDIA com 4 GB E com até 6 GB de RAM OU menos de 4 threads) recebe o Gargantua Lite
|
| 259 |
-
# (
|
| 260 |
maquina_fraca() {
|
| 261 |
case "${TARS_GARGANTUA:-}" in lite) return 0 ;; completo) return 1 ;; esac
|
| 262 |
local vram=0 ram threads
|
|
@@ -269,7 +278,7 @@ maquina_fraca() {
|
|
| 269 |
[ "${ram:-99999}" -le 6200 ] || [ "${threads:-4}" -lt 4 ]
|
| 270 |
}
|
| 271 |
if [ -z "${TARS_MODELO_GARGANTUA:-}" ] && maquina_fraca; then
|
| 272 |
-
MODELO_GARGANTUA="ProjectOpenTARS/Gargantua-2:lite"
|
| 273 |
fi
|
| 274 |
|
| 275 |
gargantua_instalado() {
|
|
|
|
| 20 |
MODELO_AJUDANTE_ANTIGO="gemma3:270m"
|
| 21 |
DIR_OPENTARS="/usr/share/opentars"
|
| 22 |
VENV="$DIR_OPENTARS/venv"
|
| 23 |
+
DEPENDENCIAS=("requests:requests" "psutil:psutil" "pyautogui:pyautogui" "PIL:pillow" "PySide6:PySide6-Essentials" "numpy:numpy" "onnxruntime:onnxruntime" "keyring:keyring")
|
| 24 |
|
| 25 |
# Textos no idioma do sistema: vêm de idiomas/*.json, pelo mesmo módulo
|
| 26 |
# que o openTARS usa (tars_i18n.py). Se algo falhar, aparecem as chaves
|
|
|
|
| 100 |
# em Docker, via Snap...) conta como instalado: instalar outro por cima
|
| 101 |
# criaria um segundo serviço brigando pela mesma porta.
|
| 102 |
# ------------------------------------------------------------------
|
| 103 |
+
# 5.3: o motor padrão é o llama.cpp, que o próprio app baixa no primeiro uso (sem senha), junto com o Gargantua 2.5.
|
| 104 |
+
# O Ollama só entra se a pessoa escolher: TARS_MOTOR=ollama, ou "motor": "ollama" na config (Configurações > Avançado).
|
| 105 |
+
USAR_OLLAMA=0
|
| 106 |
+
if [ "${TARS_MOTOR:-}" = "ollama" ] || grep -qs '"motor": *"ollama"' "$HOME/.config/opentars/config.json"; then
|
| 107 |
+
USAR_OLLAMA=1
|
| 108 |
+
fi
|
| 109 |
+
if [ "$USAR_OLLAMA" != 1 ]; then
|
| 110 |
+
msg "$(t motor_llamacpp)"
|
| 111 |
+
elif command -v ollama >/dev/null 2>&1; then
|
| 112 |
msg "$(t ollama_ja)"
|
| 113 |
elif ollama_responde; then
|
| 114 |
msg "$(t ollama_existente url="$OLLAMA_URL")"
|
|
|
|
| 230 |
# Recém-instalado, o serviço leva um instante pra subir. Se o Ollama
|
| 231 |
# existe mas o serviço está parado (sem systemd, por exemplo), tenta
|
| 232 |
# iniciar.
|
| 233 |
+
if [ "$USAR_OLLAMA" = 1 ] && ! ollama_responde && command -v systemctl >/dev/null 2>&1 \
|
| 234 |
&& systemctl list-unit-files ollama.service >/dev/null 2>&1; then
|
| 235 |
systemctl start ollama >/dev/null 2>&1 || true
|
| 236 |
fi
|
| 237 |
|
| 238 |
for _ in $(seq 1 10); do
|
| 239 |
+
[ "$USAR_OLLAMA" = 1 ] || break # com o llama.cpp os modelos vêm pelo app (passos 3d e 4 ficam de fora)
|
| 240 |
if ollama_responde; then
|
| 241 |
servico_pronto=1
|
| 242 |
break
|
|
|
|
| 247 |
# 3.0.5: nenhum modelo ajudante é baixado. Quem decide o tipo de cada
|
| 248 |
# pedido é a helper Murph, que já vem no pacote (modelos/murph.json).
|
| 249 |
msg "$(t helper_murph versao="1.0")"
|
| 250 |
+
if [ "$servico_pronto" -eq 0 ] && [ "$USAR_OLLAMA" = 1 ]; then
|
| 251 |
aviso "$(t ollama_nao_respondeu url="$OLLAMA_URL")"
|
| 252 |
# 4.5: WSL (e contêiner) costuma não ter systemd: o serviço não sobe sozinho
|
| 253 |
if em_wsl || ! command -v systemctl >/dev/null 2>&1; then
|
|
|
|
| 262 |
# Com ele instalado, o passo 4 não baixa outro modelo de conversa.
|
| 263 |
# TARS_SEM_GARGANTUA=1 pula; TARS_MODELO_GARGANTUA=<nome> troca.
|
| 264 |
# ------------------------------------------------------------------
|
| 265 |
+
MODELO_GARGANTUA="${TARS_MODELO_GARGANTUA:-ProjectOpenTARS/Gargantua-2.5}"
|
| 266 |
|
| 267 |
# 5.1: máquina fraca (sem placa NVIDIA com 4 GB E com até 6 GB de RAM OU menos de 4 threads) recebe o Gargantua Lite
|
| 268 |
+
# (Gemma 4 E2B com visão e áudio, 4,4 GB, Gargantua-2.5:lite). TARS_GARGANTUA=lite|completo força. A mesma regra do primeiro uso (tars_primeiro_uso.py).
|
| 269 |
maquina_fraca() {
|
| 270 |
case "${TARS_GARGANTUA:-}" in lite) return 0 ;; completo) return 1 ;; esac
|
| 271 |
local vram=0 ram threads
|
|
|
|
| 278 |
[ "${ram:-99999}" -le 6200 ] || [ "${threads:-4}" -lt 4 ]
|
| 279 |
}
|
| 280 |
if [ -z "${TARS_MODELO_GARGANTUA:-}" ] && maquina_fraca; then
|
| 281 |
+
MODELO_GARGANTUA="ProjectOpenTARS/Gargantua-2.5:lite"
|
| 282 |
fi
|
| 283 |
|
| 284 |
gargantua_instalado() {
|
app/empacotamento/windows/instalar.ps1
CHANGED
|
@@ -3,15 +3,16 @@
|
|
| 3 |
# irm https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/windows/instalar.ps1 | iex
|
| 4 |
#
|
| 5 |
# O que faz: baixa o código do app (pasta app/ do repositório), instala o uv (um programa que traz o Python), cria um
|
| 6 |
-
# ambiente Python com as dependências,
|
| 7 |
-
# no Menu Iniciar e os comandos opentars e opentars-gui. O modelo (Gargantua 2)
|
| 8 |
# Rodar de novo atualiza. Remover:
|
| 9 |
# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/windows/instalar.ps1))) -Remover
|
| 10 |
# Suas conversas e configurações (em %APPDATA%\opentars) ficam; apague a pasta se quiser.
|
| 11 |
param(
|
| 12 |
[switch]$Remover,
|
| 13 |
[string]$Fonte = "", # pasta app/ local em vez de baixar (o CI usa)
|
| 14 |
-
[switch]$SemOllama
|
|
|
|
| 15 |
)
|
| 16 |
$ErrorActionPreference = "Stop"
|
| 17 |
$ProgressPreference = "SilentlyContinue" # a barra do Invoke-WebRequest deixa o download muito lento no 5.1
|
|
@@ -87,11 +88,11 @@ if (-not (Test-Path (Join-Path $Venv "Scripts\python.exe"))) {
|
|
| 87 |
& $uvExe venv --python 3.12 $Venv
|
| 88 |
if ($LASTEXITCODE) { throw "uv venv failed ($LASTEXITCODE)" }
|
| 89 |
}
|
| 90 |
-
& $uvExe pip install --python (Join-Path $Venv "Scripts\python.exe") requests psutil pyautogui pillow PySide6-Essentials numpy onnxruntime uiautomation pip
|
| 91 |
if ($LASTEXITCODE) { throw "uv pip install failed ($LASTEXITCODE)" }
|
| 92 |
|
| 93 |
-
# 3. O Ollama (por usuário).
|
| 94 |
-
if (-not $SemOllama) {
|
| 95 |
$ollama = Get-Command ollama -ErrorAction SilentlyContinue
|
| 96 |
$ollamaExe = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe"
|
| 97 |
if (-not $ollama -and -not (Test-Path $ollamaExe)) {
|
|
@@ -146,7 +147,7 @@ $atalho.Description = "openTARS"
|
|
| 146 |
$atalho.IconLocation = (Join-Path $App "empacotamento\windows\opentars.ico") + ",0" # sem isto o atalho mostra o ícone do pythonw
|
| 147 |
$atalho.Save()
|
| 148 |
|
| 149 |
-
Dizer "Pronto. Abra o openTARS pelo Menu Iniciar (na primeira vez ele baixa o Gargantua 2, com barra)." `
|
| 150 |
-
"Done. Open openTARS from the Start menu (the first time it downloads Gargantua 2, with a progress bar)."
|
| 151 |
Dizer "Num terminal NOVO: 'opentars --diagnostico' confere tudo e 'opentars --prova' mede a IA; mande os relatórios que eles salvam." `
|
| 152 |
"In a NEW terminal: 'opentars --diagnostico' checks everything and 'opentars --prova' measures the AI; send the reports they save."
|
|
|
|
| 3 |
# irm https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/windows/instalar.ps1 | iex
|
| 4 |
#
|
| 5 |
# O que faz: baixa o código do app (pasta app/ do repositório), instala o uv (um programa que traz o Python), cria um
|
| 6 |
+
# ambiente Python com as dependências, cria o atalho
|
| 7 |
+
# no Menu Iniciar e os comandos opentars e opentars-gui. O motor llama.cpp e o modelo (Gargantua 2.5) são baixados na primeira abertura, com barra (-ComOllama instala o Ollama também).
|
| 8 |
# Rodar de novo atualiza. Remover:
|
| 9 |
# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/windows/instalar.ps1))) -Remover
|
| 10 |
# Suas conversas e configurações (em %APPDATA%\opentars) ficam; apague a pasta se quiser.
|
| 11 |
param(
|
| 12 |
[switch]$Remover,
|
| 13 |
[string]$Fonte = "", # pasta app/ local em vez de baixar (o CI usa)
|
| 14 |
+
[switch]$SemOllama, # aceito por compatibilidade (CI, setup.exe): desde a 5.3 o Ollama não entra por padrão
|
| 15 |
+
[switch]$ComOllama # 5.3: o motor é o llama.cpp (o app baixa); -ComOllama instala o Ollama também
|
| 16 |
)
|
| 17 |
$ErrorActionPreference = "Stop"
|
| 18 |
$ProgressPreference = "SilentlyContinue" # a barra do Invoke-WebRequest deixa o download muito lento no 5.1
|
|
|
|
| 88 |
& $uvExe venv --python 3.12 $Venv
|
| 89 |
if ($LASTEXITCODE) { throw "uv venv failed ($LASTEXITCODE)" }
|
| 90 |
}
|
| 91 |
+
& $uvExe pip install --python (Join-Path $Venv "Scripts\python.exe") requests psutil pyautogui pillow PySide6-Essentials numpy onnxruntime keyring uiautomation pip
|
| 92 |
if ($LASTEXITCODE) { throw "uv pip install failed ($LASTEXITCODE)" }
|
| 93 |
|
| 94 |
+
# 3. O Ollama (por usuário), só com -ComOllama: desde a 5.3 o app usa o motor llama.cpp, que ele mesmo baixa.
|
| 95 |
+
if ($ComOllama -and -not $SemOllama) {
|
| 96 |
$ollama = Get-Command ollama -ErrorAction SilentlyContinue
|
| 97 |
$ollamaExe = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe"
|
| 98 |
if (-not $ollama -and -not (Test-Path $ollamaExe)) {
|
|
|
|
| 147 |
$atalho.IconLocation = (Join-Path $App "empacotamento\windows\opentars.ico") + ",0" # sem isto o atalho mostra o ícone do pythonw
|
| 148 |
$atalho.Save()
|
| 149 |
|
| 150 |
+
Dizer "Pronto. Abra o openTARS pelo Menu Iniciar (na primeira vez ele baixa o Gargantua 2.5, com barra)." `
|
| 151 |
+
"Done. Open openTARS from the Start menu (the first time it downloads Gargantua 2.5, with a progress bar)."
|
| 152 |
Dizer "Num terminal NOVO: 'opentars --diagnostico' confere tudo e 'opentars --prova' mede a IA; mande os relatórios que eles salvam." `
|
| 153 |
"In a NEW terminal: 'opentars --diagnostico' checks everything and 'opentars --prova' measures the AI; send the reports they save."
|
app/empacotamento/windows/opentars.iss
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
; Instalador .exe do openTARS para Windows: só embrulha o instalar.ps1 (que continua sendo a fonte de verdade), para o winget
|
| 2 |
+
; ter um instalador de verdade e para o openTARS aparecer em "Aplicativos instalados". Sem administrador, sem assinatura paga.
|
| 3 |
+
; Compilar (Windows, Inno Setup 6): iscc /DVersao=5.3 windows\opentars.iss (a saída vai para dist\opentars-setup.exe)
|
| 4 |
+
#ifndef Versao
|
| 5 |
+
#define Versao "0.0"
|
| 6 |
+
#endif
|
| 7 |
+
[Setup]
|
| 8 |
+
AppId={{C6BAED4F-7BE2-421A-A5D8-3516502824C6}
|
| 9 |
+
AppName=openTARS
|
| 10 |
+
UninstallDisplayName=openTARS
|
| 11 |
+
AppVersion={#Versao}
|
| 12 |
+
AppPublisher=project-opentars
|
| 13 |
+
AppPublisherURL=https://project-opentars.github.io/OpenTARS/
|
| 14 |
+
AppSupportURL=https://github.com/project-opentars/OpenTARS/issues
|
| 15 |
+
DefaultDirName={localappdata}\openTARS-setup
|
| 16 |
+
DisableDirPage=yes
|
| 17 |
+
DisableProgramGroupPage=yes
|
| 18 |
+
PrivilegesRequired=lowest
|
| 19 |
+
OutputDir=..\..\dist
|
| 20 |
+
OutputBaseFilename=opentars-setup
|
| 21 |
+
SetupIconFile=opentars.ico
|
| 22 |
+
UninstallDisplayIcon={localappdata}\openTARS\app\empacotamento\windows\opentars.ico
|
| 23 |
+
Compression=lzma2
|
| 24 |
+
SolidCompression=yes
|
| 25 |
+
WizardStyle=modern
|
| 26 |
+
ArchitecturesInstallIn64BitMode=x64compatible
|
| 27 |
+
|
| 28 |
+
[Files]
|
| 29 |
+
Source: "instalar.ps1"; DestDir: "{app}"; Flags: ignoreversion
|
| 30 |
+
Source: "..\..\*"; DestDir: "{app}\app"; Excludes: "tests,gargantua,docs,dist,__pycache__,*.pyc,.git"; Flags: ignoreversion recursesubdirs createallsubdirs
|
| 31 |
+
|
| 32 |
+
[Run]
|
| 33 |
+
; o instalar.ps1 faz o resto (Python pelo uv, dependências, atalhos, PATH); com /SemOllama o CI pula o download do Ollama
|
| 34 |
+
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\instalar.ps1"" -Fonte ""{app}\app""{code:SemOllama}"; \
|
| 35 |
+
StatusMsg: "Instalando o openTARS (alguns minutos na primeira vez)..."; Flags: runhidden waituntilterminated
|
| 36 |
+
|
| 37 |
+
[UninstallRun]
|
| 38 |
+
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\instalar.ps1"" -Remover"; Flags: runhidden waituntilterminated; RunOnceId: "RemoverOpenTARS"
|
| 39 |
+
|
| 40 |
+
[Code]
|
| 41 |
+
function SemOllama(Param: String): String;
|
| 42 |
+
begin
|
| 43 |
+
if WizardSilent and (ExpandConstant('{param:SemOllama|0}') = '1') then Result := ' -SemOllama' else Result := '';
|
| 44 |
+
end;
|
app/empacotamento/winget/gerar.sh
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Gera os 3 manifestos do winget para uma Release do openTARS.
|
| 3 |
+
# bash gerar.sh 5.3 CAMINHO/opentars-setup.exe [saida]
|
| 4 |
+
# Depois: copiar a pasta para manifests/p/project-opentars/openTARS/<versao>/ de um fork de microsoft/winget-pkgs e abrir o PR.
|
| 5 |
+
set -euo pipefail
|
| 6 |
+
VERSAO="$1"; EXE="$2"; SAIDA="${3:-saida/$VERSAO}"
|
| 7 |
+
SHA=$(sha256sum "$EXE" | cut -d' ' -f1 | tr a-f A-F)
|
| 8 |
+
URL="https://github.com/project-opentars/OpenTARS/releases/download/$VERSAO/opentars-setup.exe"
|
| 9 |
+
mkdir -p "$SAIDA"
|
| 10 |
+
P=project-opentars.openTARS
|
| 11 |
+
cat > "$SAIDA/$P.yaml" <<Y
|
| 12 |
+
# yaml-language-server: \$schema=https://aka.ms/winget-manifest.version.1.6.0.schema.json
|
| 13 |
+
PackageIdentifier: $P
|
| 14 |
+
PackageVersion: $VERSAO
|
| 15 |
+
DefaultLocale: en-US
|
| 16 |
+
ManifestType: version
|
| 17 |
+
ManifestVersion: 1.6.0
|
| 18 |
+
Y
|
| 19 |
+
cat > "$SAIDA/$P.installer.yaml" <<Y
|
| 20 |
+
# yaml-language-server: \$schema=https://aka.ms/winget-manifest.installer.1.6.0.schema.json
|
| 21 |
+
PackageIdentifier: $P
|
| 22 |
+
PackageVersion: $VERSAO
|
| 23 |
+
InstallerType: inno
|
| 24 |
+
Scope: user
|
| 25 |
+
InstallModes: [interactive, silent, silentWithProgress]
|
| 26 |
+
InstallerSwitches:
|
| 27 |
+
Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
| 28 |
+
SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART
|
| 29 |
+
UpgradeBehavior: install
|
| 30 |
+
ReleaseDate: $(date +%F)
|
| 31 |
+
Installers:
|
| 32 |
+
- Architecture: x64
|
| 33 |
+
InstallerUrl: $URL
|
| 34 |
+
InstallerSha256: $SHA
|
| 35 |
+
ManifestType: installer
|
| 36 |
+
ManifestVersion: 1.6.0
|
| 37 |
+
Y
|
| 38 |
+
cat > "$SAIDA/$P.locale.en-US.yaml" <<Y
|
| 39 |
+
# yaml-language-server: \$schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json
|
| 40 |
+
PackageIdentifier: $P
|
| 41 |
+
PackageVersion: $VERSAO
|
| 42 |
+
PackageLocale: en-US
|
| 43 |
+
Publisher: project-opentars
|
| 44 |
+
PublisherUrl: https://project-opentars.github.io/OpenTARS/
|
| 45 |
+
PublisherSupportUrl: https://github.com/project-opentars/OpenTARS/issues
|
| 46 |
+
PackageName: openTARS
|
| 47 |
+
PackageUrl: https://project-opentars.github.io/OpenTARS/
|
| 48 |
+
License: MIT
|
| 49 |
+
LicenseUrl: https://github.com/project-opentars/OpenTARS/blob/main/LICENSE
|
| 50 |
+
ShortDescription: A 100% local AI assistant for your computer, local by default.
|
| 51 |
+
Description: openTARS runs small AI models on your own machine (llama.cpp), controls apps, files and the screen with your approval, and keeps your data at home. Cloud is optional and off by default.
|
| 52 |
+
Moniker: opentars
|
| 53 |
+
Tags: [ai, assistant, local, llm, privacy, llama-cpp]
|
| 54 |
+
ManifestType: defaultLocale
|
| 55 |
+
ManifestVersion: 1.6.0
|
| 56 |
+
Y
|
| 57 |
+
echo "Manifestos em $SAIDA (SHA-256 $SHA)"
|
app/idiomas/en.json
CHANGED
|
@@ -466,7 +466,7 @@
|
|
| 466 |
"term.modo_auto_explica": "openTARS picks the best AI for each task by itself.",
|
| 467 |
"term.voce": "You",
|
| 468 |
"term.conversa_carregada": "previous conversation: {n} request(s) · /clear starts over",
|
| 469 |
-
"term.ajuda": "openTARS {versao} · local AI assistant
|
| 470 |
"idioma.lista_titulo": "Available languages:",
|
| 471 |
"idioma.como_trocar": "Change it with: /language <code> (e.g. /language pt_BR)",
|
| 472 |
"idioma.trocado": "Language: {nome}",
|
|
@@ -687,8 +687,8 @@
|
|
| 687 |
"gargantua.desligado": "{modelo} installed, but TARS_SEM_GARGANTUA=1: AUTO does not prefer it",
|
| 688 |
"gargantua.nao_instalado": "Gargantua is not installed. To get the official openTARS AI: ollama pull {modelo}",
|
| 689 |
"setup.gargantua_ja": "Gargantua, the official openTARS AI, is already installed ({modelo}).",
|
| 690 |
-
"setup.gargantua_baixando": "Downloading Gargantua, the official openTARS AI ({modelo}, ~
|
| 691 |
-
"setup.gargantua_segundo_plano": "There is no terminal here to show progress (install from the app store or an automatic install): the Gargantua download (~
|
| 692 |
"setup.gargantua_pulado": "TARS_SEM_GARGANTUA=1: Gargantua was not downloaded.",
|
| 693 |
"msg.folego": "long task: {etapas} steps, {acoes} actions done; continuing",
|
| 694 |
"versao.nova": "openTARS {versao} is out! To update, run the install command again (it's in the GitHub README).",
|
|
@@ -717,7 +717,7 @@
|
|
| 717 |
"pu.slogan": "Your assistant that uses your computer, without leaving it.",
|
| 718 |
"pu.passo": "Step {n} of {total}",
|
| 719 |
"pu.passo.idioma": "Language",
|
| 720 |
-
"pu.passo.ollama": "
|
| 721 |
"pu.passo.nuvem": "Cloud (optional)",
|
| 722 |
"pu.passo.gargantua": "Download the models",
|
| 723 |
"pu.passo.teste": "Quick test",
|
|
@@ -726,16 +726,16 @@
|
|
| 726 |
"pu.pausar": "Pause",
|
| 727 |
"pu.retomar": "Resume",
|
| 728 |
"pu.tentar_de_novo": "Try again",
|
| 729 |
-
"pu.ollama.titulo": "
|
| 730 |
-
"pu.ollama.texto": "
|
| 731 |
-
"pu.ollama.procurando": "Looking for
|
| 732 |
-
"pu.ollama.ok": "
|
| 733 |
"pu.ollama.parado": "Ollama is installed, but not running.",
|
| 734 |
-
"pu.ollama.faltando": "
|
| 735 |
-
"pu.ollama.instalar": "Install
|
| 736 |
"pu.ollama.iniciar": "Start Ollama",
|
| 737 |
"pu.ollama.procurar_de_novo": "Look again",
|
| 738 |
-
"pu.ollama.instalando": "
|
| 739 |
"pu.ollama.iniciando": "Starting Ollama…",
|
| 740 |
"pu.ollama.erro_instalar": "I couldn't install it: {motivo}",
|
| 741 |
"pu.ollama.abri_download": "I opened the Ollama download page in your browser. Install it, open Ollama and click Look again.",
|
|
@@ -983,6 +983,7 @@
|
|
| 983 |
"cfg.voz.instalada": "Voice installed.",
|
| 984 |
"cfg.voz.erro": "Couldn't install: {erro}",
|
| 985 |
"cfg.voz.fala": "Speech",
|
|
|
|
| 986 |
"cfg.voz.responder_falando": "Answer out loud",
|
| 987 |
"cfg.voz.voz": "Voice",
|
| 988 |
"cfg.voz.velocidade": "Speed",
|
|
@@ -1126,9 +1127,15 @@
|
|
| 1126 |
"mascote.dica8": "Everything runs on your computer: nothing you write leaves it, unless you turn on the cloud.",
|
| 1127 |
"mascote.dica9": "In Settings you can create routines: TARS does a task by itself at the time you choose.",
|
| 1128 |
"mascote.dica10": "Type /modelo auto and TARS picks the best model for each request.",
|
| 1129 |
-
"term.slogan": "local AI assistant
|
| 1130 |
"term.bem_vindo": "Welcome to openTARS!",
|
| 1131 |
"term.rotulo_ollama": "ollama:",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1132 |
"term.rotulo_modelo": "model:",
|
| 1133 |
"term.ollama_online": "connected ({host})",
|
| 1134 |
"term.ollama_offline": "offline ({host}) · start it with: sudo systemctl start ollama",
|
|
@@ -1137,18 +1144,12 @@
|
|
| 1137 |
"term.modo_fixo": "pinned",
|
| 1138 |
"term.parar": "ctrl+c to stop",
|
| 1139 |
"term.ate_logo": "See you.",
|
| 1140 |
-
"modo.lite.nome": "Lite",
|
| 1141 |
-
"modo.lite.desc": "Just the essentials: chat, talk and attach files. Everything else is automatic.",
|
| 1142 |
"modo.base.nome": "Base",
|
| 1143 |
-
"modo.base.desc": "
|
| 1144 |
-
"modo.pro.nome": "Pro",
|
| 1145 |
-
"modo.pro.desc": "Everything in Base, plus advanced settings and diagnostics.",
|
| 1146 |
-
"cfg.modo": "Mode",
|
| 1147 |
-
"cfg.modo_sub": "How much of the window you want to see. Switch any time; nothing is deleted.",
|
| 1148 |
"cfg.nav.avancado": "Advanced settings",
|
| 1149 |
"cfg.nav.diagnostico": "Diagnostics",
|
| 1150 |
"cfg.avancado.titulo": "Advanced settings",
|
| 1151 |
-
"cfg.avancado.sub": "Only used
|
| 1152 |
"cfg.av.automatico": "Automatic",
|
| 1153 |
"cfg.av.temperatura": "Temperature",
|
| 1154 |
"cfg.av.temperatura_sub": "How much the answer varies. Lower is more predictable (the default is 0.1).",
|
|
@@ -1159,9 +1160,14 @@
|
|
| 1159 |
"cfg.av.ferramentas": "Tools",
|
| 1160 |
"cfg.av.ferramentas_sub": "Untick what the AI cannot use. It still sees the tool, but openTARS refuses to run it.",
|
| 1161 |
"cfg.av.salvar": "Save",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1162 |
"cfg.av.salvo": "Saved.",
|
| 1163 |
"cfg.av.restaurar": "Back to defaults",
|
| 1164 |
-
"cfg.av.so_pro": "
|
| 1165 |
"cfg.diag.titulo": "Diagnostics",
|
| 1166 |
"cfg.diag.sub": "What openTARS did and whether the system is in order. Nothing here leaves your computer.",
|
| 1167 |
"cfg.diag.log": "Tools used",
|
|
@@ -1172,11 +1178,8 @@
|
|
| 1172 |
"cfg.diag.ambiente_sub": "Ollama, models, screen, microphone and permissions, the same as the opentars --diagnostico command.",
|
| 1173 |
"cfg.diag.conferir": "Check now",
|
| 1174 |
"cfg.diag.conferindo": "Checking…",
|
| 1175 |
-
"pu.passo.modo": "Mode",
|
| 1176 |
-
"pu.modo.titulo": "How much do you want to see?",
|
| 1177 |
-
"pu.modo.texto": "Choose the window mode. You can change it later in Settings > General.",
|
| 1178 |
"pu.garg.nome_lite": "Gargantua Lite",
|
| 1179 |
-
"pu.garg.lite_nota": "This computer has no graphics card with 4 GB and little memory: we download Gargantua Lite (
|
| 1180 |
"cmd.sempre": [
|
| 1181 |
"always",
|
| 1182 |
"a"
|
|
@@ -1214,7 +1217,7 @@
|
|
| 1214 |
"probes.titulo": "Probes",
|
| 1215 |
"probes.sub": "Small watchers that run in the background, even with the window closed, and tell you when something matters. They only look: nothing on your computer changes unless you choose it.",
|
| 1216 |
"probes.servico": "On and off",
|
| 1217 |
-
"probes.baixar_lite": "Download Gargantua Lite (
|
| 1218 |
"probes.parar_tudo": "Stop all",
|
| 1219 |
"probes.nova": "Create a Probe",
|
| 1220 |
"probes.nova_sub": "Three steps: pick what to watch, fill in what it asks and check the sentence of what it will do.",
|
|
@@ -1422,16 +1425,15 @@
|
|
| 1422 |
"probes.pp.missao": "Tells you when {quando}.",
|
| 1423 |
"avisos.dica_sino": "Probe alerts: {n} new",
|
| 1424 |
"pu.ollama_antigo": "This model needs a newer Ollama. Update it with: curl -fsSL https://ollama.com/install.sh | sh (and try again).",
|
| 1425 |
-
"
|
| 1426 |
-
"cfg.
|
| 1427 |
-
"cfg.baixar_gargantua2": "Download Gargantua 2",
|
| 1428 |
"msg.busca_nao_lida": "The search was opened in the browser, but the AI did not read the page: what it answered above comes from its own memory and may be out of date. Check the results in the browser.",
|
| 1429 |
"msg.pouca_confianca": "I am not sure about this answer: it came from the model's memory and may be wrong or made up. Check a source before relying on it.",
|
| 1430 |
"msg.verificando_fato": "Low confidence in the answer above: checking it with a web search.",
|
| 1431 |
"msg.busca_nada_aberto": "Nothing was opened on your computer: the answer came from SearXNG. If it says it opened something in the browser, that did not happen.",
|
| 1432 |
"visao.ok": "{modelo} (looks at the screen: finds buttons and describes windows)",
|
| 1433 |
-
"visao.nao_instalado": "not installed. To let openTARS see the screen, download Gargantua 2: {comando}",
|
| 1434 |
-
"visao.desligado": "{modelo} is installed but turned off (
|
| 1435 |
"visao.sem_resposta": "The model that looks at the screen did not answer ({motivo}).",
|
| 1436 |
"cfg.nav.modelos": "More models",
|
| 1437 |
"cfg.modelos.titulo": "More models",
|
|
@@ -1440,9 +1442,9 @@
|
|
| 1440 |
"modelinhos.baixando": "Downloading the photo models (~300 MB): remove the background, upscale, select by clicking and remove an object.",
|
| 1441 |
"modelinhos.pronto": "The photo models are ready.",
|
| 1442 |
"modelinhos.erro": "I couldn't download the photo models ({erro}). I'll try again next time openTARS opens.",
|
| 1443 |
-
"lite.baixando": "Downloading Gargantua 2 Lite (
|
| 1444 |
-
"lite.pronto": "Gargantua 2 Lite is ready.",
|
| 1445 |
-
"lite.erro": "I couldn't download Gargantua 2 Lite ({erro}). I'll try again next time openTARS opens.",
|
| 1446 |
"cfg.bonsai.sub": "Makes images from a sentence (\"draw a lighthouse at dusk\"). By PrismML, Apache 2.0. Optional: {tamanho} and an NVIDIA card with 6 GB (or a Mac with Apple Silicon). Works offline once installed.",
|
| 1447 |
"cfg.bonsai.instalar": "Install ({tamanho})",
|
| 1448 |
"cfg.bonsai.remover": "Remove",
|
|
@@ -1523,6 +1525,11 @@
|
|
| 1523 |
"acoes.resultado.resumir": "Summary",
|
| 1524 |
"acoes.resultado.traduzir": "Translation",
|
| 1525 |
"acoes.resultado.perguntar": "Answer",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1526 |
"acoes.pergunta_dica": "Ask something about the selection",
|
| 1527 |
"acoes.enviar_pergunta": "Send question",
|
| 1528 |
"acoes.substituir": "Replace in the text",
|
|
@@ -1533,6 +1540,7 @@
|
|
| 1533 |
"acoes.atalho_linux": "Ctrl+Alt+R",
|
| 1534 |
"acoes.atalho_windows": "Ctrl+Alt+R",
|
| 1535 |
"acoes.atalho_macos": "Ctrl+Option+R",
|
|
|
|
| 1536 |
"acoes.trecho": "Selected text: \"{trecho}\"",
|
| 1537 |
"acoes.cortado": "(only the first {n} characters are used)",
|
| 1538 |
"acoes.sem_texto": "No text selected. Select something in any app and press the shortcut again.",
|
|
@@ -1557,6 +1565,34 @@
|
|
| 1557 |
"cfg.assist.sistema_titulo": "Start with the system",
|
| 1558 |
"cfg.assist.sistema": "Open openTARS hidden when I log in",
|
| 1559 |
"cfg.assist.sistema_nota": "On by default: the shortcut answers right away from the start of the day and the icon stays near the clock. Untick it to keep openTARS from starting with the system.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1560 |
"cfg.assist.acoes_titulo": "Text actions",
|
| 1561 |
"cfg.assist.acoes_sub": "Rewrite, summarize, translate and ask about the selection. The selected text is only material: nothing written in it is run, and nothing leaves your computer.",
|
| 1562 |
"cfg.assist.acoes_modelo": "Text actions model: {modelo}.",
|
|
@@ -1653,6 +1689,12 @@
|
|
| 1653 |
"ctx.nota": "I only look at the name of the app in use and the window title. Nothing is recorded or sent. Turn it off in Settings > Assistant.",
|
| 1654 |
"ctx.selecione": "Select the text you want to check and click Check again (or use the text actions shortcut).",
|
| 1655 |
"cfg.assist.ctx_titulo": "Suggestions based on the app in use",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1656 |
"cfg.assist.ctx": "Suggest help based on the app in use (for example, checking an email)",
|
| 1657 |
"ctx.nota_config": "On by default. Every 4 seconds openTARS looks only at the name of the app and the title of the window in use, records and sends nothing, and suggests once per window.",
|
| 1658 |
"arq.acao.foto": "Edit the photo",
|
|
@@ -1695,5 +1737,84 @@
|
|
| 1695 |
"foto.salvar": "Save a copy: {nome}",
|
| 1696 |
"foto.salvar_n": "Save {n} copies",
|
| 1697 |
"foto.aviso": "Always a copy in the same folder: the original stays as it is. The copy leaves out the location and device data. Cropping, rotating and brightening are instant; removing the background, upscaling and removing an object use small models on this computer and take a few seconds.",
|
| 1698 |
-
"foto.salvas": "Copy saved: {nomes}."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1699 |
}
|
|
|
|
| 466 |
"term.modo_auto_explica": "openTARS picks the best AI for each task by itself.",
|
| 467 |
"term.voce": "You",
|
| 468 |
"term.conversa_carregada": "previous conversation: {n} request(s) · /clear starts over",
|
| 469 |
+
"term.ajuda": "openTARS {versao} · local AI assistant\n\nChat\n opentars open the chat in the terminal (continues the last one)\n opentars --new start a new conversation\n opentars --resume pick a saved conversation to continue\n opentars --plan plan mode: the AI only describes what it would do\n opentars --auto edits in this folder without asking (delete, send and install still ask)\n opentars --chat \"request\" answer one request and exit\n opentars -p \"request\" for scripts: only the answer (or --output-format json); reads stdin\n opentars-gui open the window\n opentars-gui --rapido open the quick bar (ctrl+alt+space)\n\nModels\n opentars --model <name> use this model with -p\n opentars --explicar \"request\" show how the task and the model are chosen\n opentars --avaliar-classificador measure how often Murph and automatic mode are right\n\nEnvironment\n opentars --diagnostico check the engine, screen and accessibility (changes nothing)\n opentars --autoteste diagnosis + a real test with the calculator\n opentars --setup install whatever is missing (engine, models...)\n opentars --missao-vm \"task\" run an agent mission inside a disposable virtual machine (needs the Pro model)\n\nPreferences\n opentars --idioma <code> change the language (pt_BR or en)\n opentars --atalho [keys|off] show, change or remove the global shortcut\n opentars --version show the version\n\nInside the chat, /help lists the commands. An AGENTS.md in the folder goes into every conversation.",
|
| 470 |
"idioma.lista_titulo": "Available languages:",
|
| 471 |
"idioma.como_trocar": "Change it with: /language <code> (e.g. /language pt_BR)",
|
| 472 |
"idioma.trocado": "Language: {nome}",
|
|
|
|
| 687 |
"gargantua.desligado": "{modelo} installed, but TARS_SEM_GARGANTUA=1: AUTO does not prefer it",
|
| 688 |
"gargantua.nao_instalado": "Gargantua is not installed. To get the official openTARS AI: ollama pull {modelo}",
|
| 689 |
"setup.gargantua_ja": "Gargantua, the official openTARS AI, is already installed ({modelo}).",
|
| 690 |
+
"setup.gargantua_baixando": "Downloading Gargantua, the official openTARS AI ({modelo}, ~6.4 GB)...",
|
| 691 |
+
"setup.gargantua_segundo_plano": "There is no terminal here to show progress (install from the app store or an automatic install): the Gargantua download (~6.4 GB) continues in the background. openTARS shows the progress when you open it.",
|
| 692 |
"setup.gargantua_pulado": "TARS_SEM_GARGANTUA=1: Gargantua was not downloaded.",
|
| 693 |
"msg.folego": "long task: {etapas} steps, {acoes} actions done; continuing",
|
| 694 |
"versao.nova": "openTARS {versao} is out! To update, run the install command again (it's in the GitHub README).",
|
|
|
|
| 717 |
"pu.slogan": "Your assistant that uses your computer, without leaving it.",
|
| 718 |
"pu.passo": "Step {n} of {total}",
|
| 719 |
"pu.passo.idioma": "Language",
|
| 720 |
+
"pu.passo.ollama": "AI engine",
|
| 721 |
"pu.passo.nuvem": "Cloud (optional)",
|
| 722 |
"pu.passo.gargantua": "Download the models",
|
| 723 |
"pu.passo.teste": "Quick test",
|
|
|
|
| 726 |
"pu.pausar": "Pause",
|
| 727 |
"pu.retomar": "Resume",
|
| 728 |
"pu.tentar_de_novo": "Try again",
|
| 729 |
+
"pu.ollama.titulo": "Setting up the AI engine",
|
| 730 |
+
"pu.ollama.texto": "The engine runs the AI model on your computer. openTARS uses llama.cpp; if you already have Ollama, that works too.",
|
| 731 |
+
"pu.ollama.procurando": "Looking for an AI engine on this computer…",
|
| 732 |
+
"pu.ollama.ok": "AI engine ready ({versao})",
|
| 733 |
"pu.ollama.parado": "Ollama is installed, but not running.",
|
| 734 |
+
"pu.ollama.faltando": "The AI engine is missing. It's about 35 MB, no password needed.",
|
| 735 |
+
"pu.ollama.instalar": "Install the engine",
|
| 736 |
"pu.ollama.iniciar": "Start Ollama",
|
| 737 |
"pu.ollama.procurar_de_novo": "Look again",
|
| 738 |
+
"pu.ollama.instalando": "Downloading the engine (llama.cpp)…",
|
| 739 |
"pu.ollama.iniciando": "Starting Ollama…",
|
| 740 |
"pu.ollama.erro_instalar": "I couldn't install it: {motivo}",
|
| 741 |
"pu.ollama.abri_download": "I opened the Ollama download page in your browser. Install it, open Ollama and click Look again.",
|
|
|
|
| 983 |
"cfg.voz.instalada": "Voice installed.",
|
| 984 |
"cfg.voz.erro": "Couldn't install: {erro}",
|
| 985 |
"cfg.voz.fala": "Speech",
|
| 986 |
+
"cfg.voz.resposta_completa": "Read long answers in full (by default only the first sentences are spoken; the rest stays on the screen)",
|
| 987 |
"cfg.voz.responder_falando": "Answer out loud",
|
| 988 |
"cfg.voz.voz": "Voice",
|
| 989 |
"cfg.voz.velocidade": "Speed",
|
|
|
|
| 1127 |
"mascote.dica8": "Everything runs on your computer: nothing you write leaves it, unless you turn on the cloud.",
|
| 1128 |
"mascote.dica9": "In Settings you can create routines: TARS does a task by itself at the time you choose.",
|
| 1129 |
"mascote.dica10": "Type /modelo auto and TARS picks the best model for each request.",
|
| 1130 |
+
"term.slogan": "local AI assistant",
|
| 1131 |
"term.bem_vindo": "Welcome to openTARS!",
|
| 1132 |
"term.rotulo_ollama": "ollama:",
|
| 1133 |
+
"term.rotulo_motor": "engine:",
|
| 1134 |
+
"term.rotulo_pasta": "folder:",
|
| 1135 |
+
"term.motor_offline": "offline ({host}) · restart openTARS to start the engine",
|
| 1136 |
+
"cli.regua.perguntar": "ask",
|
| 1137 |
+
"cli.regua.plano": "plan",
|
| 1138 |
+
"cli.regua.automatico": "auto",
|
| 1139 |
"term.rotulo_modelo": "model:",
|
| 1140 |
"term.ollama_online": "connected ({host})",
|
| 1141 |
"term.ollama_offline": "offline ({host}) · start it with: sudo systemctl start ollama",
|
|
|
|
| 1144 |
"term.modo_fixo": "pinned",
|
| 1145 |
"term.parar": "ctrl+c to stop",
|
| 1146 |
"term.ate_logo": "See you.",
|
|
|
|
|
|
|
| 1147 |
"modo.base.nome": "Base",
|
| 1148 |
+
"modo.base.desc": "Everything for every day: history, memory, voice, routines, Probes and cloud.",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1149 |
"cfg.nav.avancado": "Advanced settings",
|
| 1150 |
"cfg.nav.diagnostico": "Diagnostics",
|
| 1151 |
"cfg.avancado.titulo": "Advanced settings",
|
| 1152 |
+
"cfg.avancado.sub": "Only used with dev mode on. Changing these can make the AI worse: Gargantua was trained with the defaults.",
|
| 1153 |
"cfg.av.automatico": "Automatic",
|
| 1154 |
"cfg.av.temperatura": "Temperature",
|
| 1155 |
"cfg.av.temperatura_sub": "How much the answer varies. Lower is more predictable (the default is 0.1).",
|
|
|
|
| 1160 |
"cfg.av.ferramentas": "Tools",
|
| 1161 |
"cfg.av.ferramentas_sub": "Untick what the AI cannot use. It still sees the tool, but openTARS refuses to run it.",
|
| 1162 |
"cfg.av.salvar": "Save",
|
| 1163 |
+
"cfg.av.motor": "AI engine",
|
| 1164 |
+
"cfg.av.motor_sub": "llama.cpp is the default. Switch to Ollama if you prefer to keep using it. Takes effect the next time openTARS starts.",
|
| 1165 |
+
"cfg.av.motor_llamacpp": "llama.cpp (default)",
|
| 1166 |
+
"cfg.av.motor_ollama": "Ollama",
|
| 1167 |
+
"cfg.av.motor_reiniciar": "Saved. Restart openTARS to switch the engine.",
|
| 1168 |
"cfg.av.salvo": "Saved.",
|
| 1169 |
"cfg.av.restaurar": "Back to defaults",
|
| 1170 |
+
"cfg.av.so_pro": "Dev mode is off: these settings are kept, but only used when it is on (Settings > General).",
|
| 1171 |
"cfg.diag.titulo": "Diagnostics",
|
| 1172 |
"cfg.diag.sub": "What openTARS did and whether the system is in order. Nothing here leaves your computer.",
|
| 1173 |
"cfg.diag.log": "Tools used",
|
|
|
|
| 1178 |
"cfg.diag.ambiente_sub": "Ollama, models, screen, microphone and permissions, the same as the opentars --diagnostico command.",
|
| 1179 |
"cfg.diag.conferir": "Check now",
|
| 1180 |
"cfg.diag.conferindo": "Checking…",
|
|
|
|
|
|
|
|
|
|
| 1181 |
"pu.garg.nome_lite": "Gargantua Lite",
|
| 1182 |
+
"pu.garg.lite_nota": "This computer has no graphics card with 4 GB and little memory: we download Gargantua 2.5 Lite (4.4 GB, with vision and audio), which is lighter. It reads the screen but does not point to small buttons by position; clicking by name works through the app's accessibility.",
|
| 1183 |
"cmd.sempre": [
|
| 1184 |
"always",
|
| 1185 |
"a"
|
|
|
|
| 1217 |
"probes.titulo": "Probes",
|
| 1218 |
"probes.sub": "Small watchers that run in the background, even with the window closed, and tell you when something matters. They only look: nothing on your computer changes unless you choose it.",
|
| 1219 |
"probes.servico": "On and off",
|
| 1220 |
+
"probes.baixar_lite": "Download Gargantua 2.5 Lite (4.4 GB, with vision and audio)",
|
| 1221 |
"probes.parar_tudo": "Stop all",
|
| 1222 |
"probes.nova": "Create a Probe",
|
| 1223 |
"probes.nova_sub": "Three steps: pick what to watch, fill in what it asks and check the sentence of what it will do.",
|
|
|
|
| 1425 |
"probes.pp.missao": "Tells you when {quando}.",
|
| 1426 |
"avisos.dica_sino": "Probe alerts: {n} new",
|
| 1427 |
"pu.ollama_antigo": "This model needs a newer Ollama. Update it with: curl -fsSL https://ollama.com/install.sh | sh (and try again).",
|
| 1428 |
+
"cfg.gargantua_novo": "You have Gargantua 1 or an older model. Gargantua 2.5 is openTARS's main AI, on Gemma 4: 6.3 GB, with vision and audio support. Worth downloading.",
|
| 1429 |
+
"cfg.baixar_gargantua2": "Download Gargantua 2.5",
|
|
|
|
| 1430 |
"msg.busca_nao_lida": "The search was opened in the browser, but the AI did not read the page: what it answered above comes from its own memory and may be out of date. Check the results in the browser.",
|
| 1431 |
"msg.pouca_confianca": "I am not sure about this answer: it came from the model's memory and may be wrong or made up. Check a source before relying on it.",
|
| 1432 |
"msg.verificando_fato": "Low confidence in the answer above: checking it with a web search.",
|
| 1433 |
"msg.busca_nada_aberto": "Nothing was opened on your computer: the answer came from SearXNG. If it says it opened something in the browser, that did not happen.",
|
| 1434 |
"visao.ok": "{modelo} (looks at the screen: finds buttons and describes windows)",
|
| 1435 |
+
"visao.nao_instalado": "not installed. To let openTARS see the screen, download Gargantua 2.5: {comando}",
|
| 1436 |
+
"visao.desligado": "{modelo} is installed but turned off (TARS_VISAO=0).",
|
| 1437 |
"visao.sem_resposta": "The model that looks at the screen did not answer ({motivo}).",
|
| 1438 |
"cfg.nav.modelos": "More models",
|
| 1439 |
"cfg.modelos.titulo": "More models",
|
|
|
|
| 1442 |
"modelinhos.baixando": "Downloading the photo models (~300 MB): remove the background, upscale, select by clicking and remove an object.",
|
| 1443 |
"modelinhos.pronto": "The photo models are ready.",
|
| 1444 |
"modelinhos.erro": "I couldn't download the photo models ({erro}). I'll try again next time openTARS opens.",
|
| 1445 |
+
"lite.baixando": "Downloading Gargantua 2.5 Lite (4.4 GB), the AI that runs in the background for the assistant, text actions and Probes.",
|
| 1446 |
+
"lite.pronto": "Gargantua 2.5 Lite is ready.",
|
| 1447 |
+
"lite.erro": "I couldn't download Gargantua 2.5 Lite ({erro}). I'll try again next time openTARS opens.",
|
| 1448 |
"cfg.bonsai.sub": "Makes images from a sentence (\"draw a lighthouse at dusk\"). By PrismML, Apache 2.0. Optional: {tamanho} and an NVIDIA card with 6 GB (or a Mac with Apple Silicon). Works offline once installed.",
|
| 1449 |
"cfg.bonsai.instalar": "Install ({tamanho})",
|
| 1450 |
"cfg.bonsai.remover": "Remove",
|
|
|
|
| 1525 |
"acoes.resultado.resumir": "Summary",
|
| 1526 |
"acoes.resultado.traduzir": "Translation",
|
| 1527 |
"acoes.resultado.perguntar": "Answer",
|
| 1528 |
+
"acoes.aba.responder": "Reply",
|
| 1529 |
+
"acoes.opcao.curto": "Shorter",
|
| 1530 |
+
"acoes.opcao.lista": "Bullet list",
|
| 1531 |
+
"acoes.resultado.responder": "Draft reply",
|
| 1532 |
+
"acoes.responder_dica": "What do you want to say? (e.g. I can make it tomorrow)",
|
| 1533 |
"acoes.pergunta_dica": "Ask something about the selection",
|
| 1534 |
"acoes.enviar_pergunta": "Send question",
|
| 1535 |
"acoes.substituir": "Replace in the text",
|
|
|
|
| 1540 |
"acoes.atalho_linux": "Ctrl+Alt+R",
|
| 1541 |
"acoes.atalho_windows": "Ctrl+Alt+R",
|
| 1542 |
"acoes.atalho_macos": "Ctrl+Option+R",
|
| 1543 |
+
"acoes.da_janela": "Text read from the window: {janela}",
|
| 1544 |
"acoes.trecho": "Selected text: \"{trecho}\"",
|
| 1545 |
"acoes.cortado": "(only the first {n} characters are used)",
|
| 1546 |
"acoes.sem_texto": "No text selected. Select something in any app and press the shortcut again.",
|
|
|
|
| 1565 |
"cfg.assist.sistema_titulo": "Start with the system",
|
| 1566 |
"cfg.assist.sistema": "Open openTARS hidden when I log in",
|
| 1567 |
"cfg.assist.sistema_nota": "On by default: the shortcut answers right away from the start of the day and the icon stays near the clock. Untick it to keep openTARS from starting with the system.",
|
| 1568 |
+
"cfg.assist.agenda_titulo": "Calendar",
|
| 1569 |
+
"cfg.assist.agenda_sub": "Ask \"what do I have tomorrow?\". Point to a .ics file or a calendar link (Google, Outlook and Apple can export one). Read only; off until you set it.",
|
| 1570 |
+
"cfg.assist.agenda_dica": "/path/to/calendar.ics or https://… (separate several with ;)",
|
| 1571 |
+
"cfg.assist.agenda_ligada": "On: reading {n} calendar(s). openTARS never changes your calendar.",
|
| 1572 |
+
"cfg.assist.agenda_nao_achei": "I can't find {fonte}: use a file that exists or a link that starts with https://",
|
| 1573 |
+
"cfg.assist.desligada": "Off.",
|
| 1574 |
+
"cfg.assist.notas_titulo": "Notes",
|
| 1575 |
+
"cfg.assist.notas_sub": "Say \"create a note: …\" or \"my notes about …\". Notes are Markdown files in the folder you choose; off until you set it.",
|
| 1576 |
+
"cfg.assist.notas_dica": "/path/to/Notes (Obsidian and Joplin folders work)",
|
| 1577 |
+
"cfg.assist.notas_ligada": "On: notes are saved and searched in this folder. A note is never written over another one.",
|
| 1578 |
+
"cfg.assist.salvar": "Save",
|
| 1579 |
+
"cfg.assist.email_titulo": "Email",
|
| 1580 |
+
"cfg.assist.email_sub": "Ask \"what email came in today?\", \"read the last email from Ana\" or \"send an email to ana@example.com saying…\". Reading never marks anything as read; sending always asks you first. Off until you set it.",
|
| 1581 |
+
"cfg.assist.email_usuario": "Email address",
|
| 1582 |
+
"cfg.assist.email_senha": "Password (Gmail and Outlook need an app password)",
|
| 1583 |
+
"cfg.assist.email_senha_guardada": "Password saved in the system keychain (type a new one to change it)",
|
| 1584 |
+
"cfg.assist.email_imap": "Incoming server (IMAP), blank for Gmail, Outlook, iCloud, Yahoo",
|
| 1585 |
+
"cfg.assist.email_smtp": "Outgoing server (SMTP), blank for the same providers",
|
| 1586 |
+
"cfg.assist.email_ligado": "On: {usuario}. The password stays in the system keychain, never in a file.",
|
| 1587 |
+
"cfg.assist.email_servidores": "I don't know this provider: fill in the IMAP and SMTP servers.",
|
| 1588 |
+
"cfg.assist.email_sem_senha": "Type the password.",
|
| 1589 |
+
"cfg.assist.email_sem_cofre": "This computer has no password keychain for openTARS (install the keyring package). I did not save anything.",
|
| 1590 |
+
"cfg.assist.email_testar": "Test",
|
| 1591 |
+
"cfg.assist.email_desligar": "Turn off and forget",
|
| 1592 |
+
"cfg.assist.email_testando": "Testing…",
|
| 1593 |
+
"cfg.assist.email_ok": "Connected. Nothing was changed in your mailbox.",
|
| 1594 |
+
"cfg.assist.email_erro": "It didn't work: {erro}",
|
| 1595 |
+
"cfg.assist.email_salvar_antes": "Save the account first.",
|
| 1596 |
"cfg.assist.acoes_titulo": "Text actions",
|
| 1597 |
"cfg.assist.acoes_sub": "Rewrite, summarize, translate and ask about the selection. The selected text is only material: nothing written in it is run, and nothing leaves your computer.",
|
| 1598 |
"cfg.assist.acoes_modelo": "Text actions model: {modelo}.",
|
|
|
|
| 1689 |
"ctx.nota": "I only look at the name of the app in use and the window title. Nothing is recorded or sent. Turn it off in Settings > Assistant.",
|
| 1690 |
"ctx.selecione": "Select the text you want to check and click Check again (or use the text actions shortcut).",
|
| 1691 |
"cfg.assist.ctx_titulo": "Suggestions based on the app in use",
|
| 1692 |
+
"cfg.assist.indice_titulo": "File search",
|
| 1693 |
+
"cfg.assist.indice": "Index my files so search is instant (off by default)",
|
| 1694 |
+
"cfg.assist.indice_nota": "The index keeps file names and the start of each text on this computer only, never sends them anywhere, and is erased when you turn this off.",
|
| 1695 |
+
"cfg.assist.indice_montando": "Building the index in the background…",
|
| 1696 |
+
"cfg.assist.indice_pronto": "Index ready: {n} files.",
|
| 1697 |
+
"cfg.assist.indice_erro": "The index failed: {erro}",
|
| 1698 |
"cfg.assist.ctx": "Suggest help based on the app in use (for example, checking an email)",
|
| 1699 |
"ctx.nota_config": "On by default. Every 4 seconds openTARS looks only at the name of the app and the title of the window in use, records and sends nothing, and suggests once per window.",
|
| 1700 |
"arq.acao.foto": "Edit the photo",
|
|
|
|
| 1737 |
"foto.salvar": "Save a copy: {nome}",
|
| 1738 |
"foto.salvar_n": "Save {n} copies",
|
| 1739 |
"foto.aviso": "Always a copy in the same folder: the original stays as it is. The copy leaves out the location and device data. Cropping, rotating and brightening are instant; removing the background, upscaling and removing an object use small models on this computer and take a few seconds.",
|
| 1740 |
+
"foto.salvas": "Copy saved: {nomes}.",
|
| 1741 |
+
"cfg.dev": "Dev mode",
|
| 1742 |
+
"cfg.dev_sub": "For people who want to adjust what is automatic.",
|
| 1743 |
+
"cfg.dev_ligar": "Show advanced settings and diagnostics",
|
| 1744 |
+
"modo.dev.nome": "Dev",
|
| 1745 |
+
"modo.dev.desc": "Everything in Base, plus advanced settings and diagnostics.",
|
| 1746 |
+
"motor.preparando": "Setting up the new engine (llama.cpp) in the background. You can keep using openTARS.",
|
| 1747 |
+
"motor.pronto": "New engine ready: openTARS now runs on llama.cpp.",
|
| 1748 |
+
"motor.erro": "Couldn't set up the new engine ({erro}). openTARS keeps running on Ollama.",
|
| 1749 |
+
"cfg.modelos.busca_ph": "Search models (name, vision, tools...)",
|
| 1750 |
+
"cfg.modelos.busca_web": "Search the Ollama library",
|
| 1751 |
+
"cfg.modelos.buscando": "Searching the Ollama library...",
|
| 1752 |
+
"cfg.modelos.busca_nada": "Nothing found in the Ollama library for this search.",
|
| 1753 |
+
"cfg.modelos.busca_erro": "Could not reach the Ollama library. Check your connection and try again.",
|
| 1754 |
+
"cfg.modelos.g.web": "From the Ollama library",
|
| 1755 |
+
"cfg.modelos.g.web_sub": "Results from ollama.com. The size shows when you click: it is checked before the download starts.",
|
| 1756 |
+
"cfg.modelos.calculando": "Checking the download size...",
|
| 1757 |
+
"cfg.assist.reabrir_titulo": "Apps that hide their buttons",
|
| 1758 |
+
"cfg.assist.reabrir": "Let openTARS reopen an app so it can click its buttons",
|
| 1759 |
+
"cfg.assist.reabrir_nota": "Chrome-based apps that were already open (Claude, Discord, VS Code, Slack, Chrome) hide their buttons from other programs. With this on, when openTARS needs to click in one of them, it closes the app and opens it again with accessibility on. Unsaved work in that app can be lost, and it never force-quits: if the app refuses to close, it stays open. Off by default.",
|
| 1760 |
+
"vm.cli.uso": "Usage: opentars --missao-vm \"what the agent should do inside the virtual machine\"",
|
| 1761 |
+
"vm.cli.falta": "The virtual machine is not ready: missing {falta}.",
|
| 1762 |
+
"vm.cli.sem_base": "The virtual machine image is not built yet.",
|
| 1763 |
+
"vm.cli.sem_pro": "This needs the Gargantua Pro model ({modelo}). Add it in Settings > More models.",
|
| 1764 |
+
"vm.cli.comecando": "Starting in a disposable virtual machine: {pedido}",
|
| 1765 |
+
"vm.cli.resultado": "Finished ({motivo}, {segundos} s): {resultado}",
|
| 1766 |
+
"vm.cli.acesso": " site visited: {acesso}",
|
| 1767 |
+
"vm.cli.arquivos": "The agent left {n} file(s) in the output folder: {lista}",
|
| 1768 |
+
"vm.cli.liberar": "Copy them to {pasta}? [y/N] ",
|
| 1769 |
+
"vm.cli.descartados": "Not copied. They were deleted with the virtual machine.",
|
| 1770 |
+
"vm.secao": "Missions in a virtual machine",
|
| 1771 |
+
"vm.secao_sub": "Gargantua Pro works on an isolated, disposable computer: it opens programs, browses and types. Nothing on your PC is visible inside, and whatever it makes only reaches you if you approve it.",
|
| 1772 |
+
"vm.abrir": "Open the VM window",
|
| 1773 |
+
"vm.so_linux": "For now, VM missions work only on Linux with KVM.",
|
| 1774 |
+
"vm.janela.titulo": "Mission in a virtual machine",
|
| 1775 |
+
"vm.janela.sub": "Write what the agent should do. It uses the mouse and keyboard of a disposable VM; when it finishes, the VM is deleted.",
|
| 1776 |
+
"vm.janela.falta": "The virtual machine is not ready: missing {falta}.",
|
| 1777 |
+
"vm.janela.sem_base": "The VM image does not exist yet. Building it takes about 5 minutes and uses about 1.6 GB.",
|
| 1778 |
+
"vm.janela.sem_pro": "VM missions use Gargantua Pro (about 7.6 GB).",
|
| 1779 |
+
"vm.janela.conferindo": "Checking the model...",
|
| 1780 |
+
"vm.janela.montar": "Build the image",
|
| 1781 |
+
"vm.janela.montando": "Building the VM image...",
|
| 1782 |
+
"vm.janela.montando_pct": "Downloading the VM system: {pct}%",
|
| 1783 |
+
"vm.janela.baixar_pro": "Download Gargantua Pro",
|
| 1784 |
+
"vm.janela.baixando": "Downloading Gargantua Pro...",
|
| 1785 |
+
"vm.janela.erro": "That did not work: {erro}",
|
| 1786 |
+
"vm.janela.pedido_ph": "E.g.: open the terminal and create notes.txt in the output folder with the shopping list",
|
| 1787 |
+
"vm.janela.comecar": "Start",
|
| 1788 |
+
"vm.janela.parar": "Stop",
|
| 1789 |
+
"vm.janela.internet": "Internet (every site is logged)",
|
| 1790 |
+
"vm.janela.sem_tela": "The VM screen shows up here.",
|
| 1791 |
+
"vm.janela.tela": "Virtual machine screen",
|
| 1792 |
+
"vm.janela.passos": "Steps",
|
| 1793 |
+
"vm.janela.sites": "Sites visited",
|
| 1794 |
+
"vm.janela.ligando": "Starting the VM...",
|
| 1795 |
+
"vm.janela.trabalhando": "The agent is working.",
|
| 1796 |
+
"vm.janela.parando": "Stopping...",
|
| 1797 |
+
"vm.janela.fim": "Finished ({motivo}, {segundos} s): {resultado}",
|
| 1798 |
+
"vm.janela.arquivos": "The agent left {n} file(s) in the output folder. Check what should come to your PC:",
|
| 1799 |
+
"vm.janela.trazer": "Bring to {pasta}",
|
| 1800 |
+
"vm.janela.descartar": "Discard all",
|
| 1801 |
+
"vm.janela.trazidos": "{n} file(s) copied to {pasta}. The rest was deleted.",
|
| 1802 |
+
"vm.janela.descartados": "Nothing was copied. The files were deleted with the VM.",
|
| 1803 |
+
"vm.motivo.terminou": "done",
|
| 1804 |
+
"vm.motivo.cancelada": "stopped by you",
|
| 1805 |
+
"vm.motivo.tempo": "time ran out",
|
| 1806 |
+
"vm.motivo.passos": "step limit",
|
| 1807 |
+
"vm.passo.clicar": "clicked at {detalhe}",
|
| 1808 |
+
"vm.passo.duplo": "double-clicked at {detalhe}",
|
| 1809 |
+
"vm.passo.direito": "right-clicked at {detalhe}",
|
| 1810 |
+
"vm.passo.atalho": "pressed {detalhe}",
|
| 1811 |
+
"vm.passo.digitou": "typed text",
|
| 1812 |
+
"vm.passo.rolou": "scrolled",
|
| 1813 |
+
"vm.passo.esperou": "waited",
|
| 1814 |
+
"cli.barra_dica": "/help · ctrl+c stops",
|
| 1815 |
+
"jp.atualizando_script": "Updating with the installer... openTARS reopens on its own when it is done.",
|
| 1816 |
+
"jp.erro_script": "The update did not finish. The log is in {arquivo}.",
|
| 1817 |
+
"setup.motor_llamacpp": "Engine: llama.cpp. openTARS downloads the engine and Gargantua 2.5 the first time it opens, with a progress bar and no password.",
|
| 1818 |
+
"g25.oferta": "Gargantua 2.5 is here, the new openTARS AI: it sees your screen and understands your voice.",
|
| 1819 |
+
"g25.ver": "Get Gargantua 2.5"
|
| 1820 |
}
|
app/idiomas/pt_BR.json
CHANGED
|
@@ -504,7 +504,7 @@
|
|
| 504 |
"term.modo_auto_explica": "o openTARS escolhe sozinho a IA mais adequada pra cada tarefa.",
|
| 505 |
"term.voce": "Você",
|
| 506 |
"term.conversa_carregada": "conversa anterior: {n} pedido(s) · /limpar começa do zero",
|
| 507 |
-
"term.ajuda": "openTARS {versao} · assistente de IA local
|
| 508 |
"idioma.lista_titulo": "Idiomas disponíveis:",
|
| 509 |
"idioma.como_trocar": "Troque com: /idioma <código> (ex: /idioma en)",
|
| 510 |
"idioma.trocado": "Idioma: {nome}",
|
|
@@ -755,8 +755,8 @@
|
|
| 755 |
"gargantua.desligado": "{modelo} instalado, mas TARS_SEM_GARGANTUA=1: o AUTO não dá preferência pra ele",
|
| 756 |
"gargantua.nao_instalado": "Gargantua não instalado. Pra baixar a IA oficial do openTARS: ollama pull {modelo}",
|
| 757 |
"setup.gargantua_ja": "Gargantua, a IA oficial do openTARS, já está instalado ({modelo}).",
|
| 758 |
-
"setup.gargantua_baixando": "Baixando o Gargantua, a IA oficial do openTARS ({modelo}, ~
|
| 759 |
-
"setup.gargantua_segundo_plano": "Sem terminal aqui para mostrar o andamento (instalação pela loja de aplicativos ou automática): o download do Gargantua (~
|
| 760 |
"setup.gargantua_pulado": "TARS_SEM_GARGANTUA=1: o Gargantua não foi baixado.",
|
| 761 |
"msg.folego": "tarefa longa: {etapas} etapas, {acoes} ações feitas; continuando",
|
| 762 |
"versao.nova": "Saiu o openTARS {versao}! Pra atualizar, rode de novo o comando de instalação (está no README do GitHub).",
|
|
@@ -785,7 +785,7 @@
|
|
| 785 |
"pu.slogan": "Seu assistente que mexe no computador, sem sair dele.",
|
| 786 |
"pu.passo": "Passo {n} de {total}",
|
| 787 |
"pu.passo.idioma": "Idioma",
|
| 788 |
-
"pu.passo.ollama": "
|
| 789 |
"pu.passo.nuvem": "Nuvem (opcional)",
|
| 790 |
"pu.passo.gargantua": "Baixar os modelos",
|
| 791 |
"pu.passo.teste": "Teste rápido",
|
|
@@ -794,16 +794,16 @@
|
|
| 794 |
"pu.pausar": "Pausar",
|
| 795 |
"pu.retomar": "Retomar",
|
| 796 |
"pu.tentar_de_novo": "Tentar de novo",
|
| 797 |
-
"pu.ollama.titulo": "
|
| 798 |
-
"pu.ollama.texto": "O
|
| 799 |
-
"pu.ollama.procurando": "Procurando
|
| 800 |
-
"pu.ollama.ok": "
|
| 801 |
"pu.ollama.parado": "O Ollama está instalado, mas parado.",
|
| 802 |
-
"pu.ollama.faltando": "
|
| 803 |
-
"pu.ollama.instalar": "Instalar o
|
| 804 |
"pu.ollama.iniciar": "Iniciar o Ollama",
|
| 805 |
"pu.ollama.procurar_de_novo": "Procurar de novo",
|
| 806 |
-
"pu.ollama.instalando": "
|
| 807 |
"pu.ollama.iniciando": "Iniciando o Ollama…",
|
| 808 |
"pu.ollama.erro_instalar": "Não consegui instalar: {motivo}",
|
| 809 |
"pu.ollama.abri_download": "Abri a página de download do Ollama no navegador. Instale, abra o Ollama e clique em Procurar de novo.",
|
|
@@ -1051,6 +1051,7 @@
|
|
| 1051 |
"cfg.voz.instalada": "Voz instalada.",
|
| 1052 |
"cfg.voz.erro": "Não deu pra instalar: {erro}",
|
| 1053 |
"cfg.voz.fala": "Fala",
|
|
|
|
| 1054 |
"cfg.voz.responder_falando": "Responder falando",
|
| 1055 |
"cfg.voz.voz": "Voz",
|
| 1056 |
"cfg.voz.velocidade": "Velocidade",
|
|
@@ -1194,9 +1195,15 @@
|
|
| 1194 |
"mascote.dica8": "Tudo roda no seu computador: nada do que você escreve sai daqui, a menos que você ligue a nuvem.",
|
| 1195 |
"mascote.dica9": "Em Configurações dá pra criar rotinas: o TARS faz uma tarefa sozinho no horário que você escolher.",
|
| 1196 |
"mascote.dica10": "Escreva /modelo auto pro TARS escolher o melhor modelo pra cada pedido.",
|
| 1197 |
-
"term.slogan": "assistente de IA local
|
| 1198 |
"term.bem_vindo": "Bem-vindo ao openTARS!",
|
| 1199 |
"term.rotulo_ollama": "ollama:",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1200 |
"term.rotulo_modelo": "modelo:",
|
| 1201 |
"term.ollama_online": "conectado ({host})",
|
| 1202 |
"term.ollama_offline": "desligado ({host}) · inicie com: sudo systemctl start ollama",
|
|
@@ -1205,18 +1212,12 @@
|
|
| 1205 |
"term.modo_fixo": "fixo",
|
| 1206 |
"term.parar": "ctrl+c para parar",
|
| 1207 |
"term.ate_logo": "Até mais.",
|
| 1208 |
-
"modo.lite.nome": "Lite",
|
| 1209 |
-
"modo.lite.desc": "Só o essencial: conversar, falar e anexar. Tudo fica no automático.",
|
| 1210 |
"modo.base.nome": "Base",
|
| 1211 |
-
"modo.base.desc": "
|
| 1212 |
-
"modo.pro.nome": "Pro",
|
| 1213 |
-
"modo.pro.desc": "Tudo do Base, mais ajustes avançados e diagnóstico.",
|
| 1214 |
-
"cfg.modo": "Modo",
|
| 1215 |
-
"cfg.modo_sub": "Quanto da janela você quer ver. Dá para trocar a qualquer hora; nada é apagado.",
|
| 1216 |
"cfg.nav.avancado": "Ajustes avançados",
|
| 1217 |
"cfg.nav.diagnostico": "Diagnóstico",
|
| 1218 |
"cfg.avancado.titulo": "Ajustes avançados",
|
| 1219 |
-
"cfg.avancado.sub": "Só valem
|
| 1220 |
"cfg.av.automatico": "Automático",
|
| 1221 |
"cfg.av.temperatura": "Temperatura",
|
| 1222 |
"cfg.av.temperatura_sub": "Quanto a resposta varia. Mais baixa é mais previsível (o padrão é 0,1).",
|
|
@@ -1227,9 +1228,14 @@
|
|
| 1227 |
"cfg.av.ferramentas": "Ferramentas",
|
| 1228 |
"cfg.av.ferramentas_sub": "Desmarque o que a IA não pode usar. Ela ainda vê a ferramenta, mas o openTARS recusa.",
|
| 1229 |
"cfg.av.salvar": "Salvar",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1230 |
"cfg.av.salvo": "Salvo.",
|
| 1231 |
"cfg.av.restaurar": "Voltar aos padrões",
|
| 1232 |
-
"cfg.av.so_pro": "
|
| 1233 |
"cfg.diag.titulo": "Diagnóstico",
|
| 1234 |
"cfg.diag.sub": "O que o openTARS fez e se o ambiente está em ordem. Nada daqui sai do seu computador.",
|
| 1235 |
"cfg.diag.log": "Ferramentas usadas",
|
|
@@ -1240,11 +1246,8 @@
|
|
| 1240 |
"cfg.diag.ambiente_sub": "Ollama, modelos, tela, microfone e permissões, o mesmo do comando opentars --diagnostico.",
|
| 1241 |
"cfg.diag.conferir": "Conferir agora",
|
| 1242 |
"cfg.diag.conferindo": "Conferindo…",
|
| 1243 |
-
"pu.passo.modo": "Modo",
|
| 1244 |
-
"pu.modo.titulo": "Quanto você quer ver?",
|
| 1245 |
-
"pu.modo.texto": "Escolha o modo da janela. Dá para trocar depois, em Configurações > Geral.",
|
| 1246 |
"pu.garg.nome_lite": "Gargantua Lite",
|
| 1247 |
-
"pu.garg.lite_nota": "Este computador não tem placa de vídeo com 4 GB e tem pouca memória: baixamos o Gargantua Lite (
|
| 1248 |
"cmd.sempre": [
|
| 1249 |
"sempre",
|
| 1250 |
"a"
|
|
@@ -1282,7 +1285,7 @@
|
|
| 1282 |
"probes.titulo": "Probes",
|
| 1283 |
"probes.sub": "Pequenos vigias que rodam em segundo plano, mesmo com a janela fechada, e avisam quando algo importa. Eles só olham: nada muda no seu computador sem você escolher.",
|
| 1284 |
"probes.servico": "Ligar e desligar",
|
| 1285 |
-
"probes.baixar_lite": "Baixar o Gargantua Lite (
|
| 1286 |
"probes.parar_tudo": "Parar tudo",
|
| 1287 |
"probes.nova": "Criar um Probe",
|
| 1288 |
"probes.nova_sub": "Três passos: escolha o que vigiar, preencha o que pedir e confira a frase do que ele vai fazer.",
|
|
@@ -1490,16 +1493,15 @@
|
|
| 1490 |
"probes.pp.missao": "Avisa quando {quando}.",
|
| 1491 |
"avisos.dica_sino": "Avisos dos Probes: {n} novo(s)",
|
| 1492 |
"pu.ollama_antigo": "Este modelo pede um Ollama mais novo. Atualize com: curl -fsSL https://ollama.com/install.sh | sh (e tente de novo).",
|
| 1493 |
-
"
|
| 1494 |
-
"cfg.
|
| 1495 |
-
"cfg.baixar_gargantua2": "Baixar o Gargantua 2",
|
| 1496 |
"msg.busca_nao_lida": "A busca foi aberta no navegador, mas a IA não leu a página: o que ela respondeu acima vem da memória dela e pode estar desatualizado. Confira os resultados no navegador.",
|
| 1497 |
"msg.pouca_confianca": "Não tenho certeza dessa resposta: ela veio da memória do modelo e pode estar errada ou inventada. Confira numa fonte antes de usar.",
|
| 1498 |
"msg.verificando_fato": "Pouca confiança na resposta acima: vou conferir pela busca na web.",
|
| 1499 |
"msg.busca_nada_aberto": "Nada foi aberto no seu computador: a resposta veio do SearXNG. Se ela diz que abriu algo no navegador, isso não aconteceu.",
|
| 1500 |
"visao.ok": "{modelo} (olha a tela: acha botões e descreve janelas)",
|
| 1501 |
-
"visao.nao_instalado": "não instalado. Para o openTARS enxergar a tela, baixe o Gargantua 2: {comando}",
|
| 1502 |
-
"visao.desligado": "{modelo} está instalado, mas desligado (
|
| 1503 |
"visao.sem_resposta": "O modelo que olha a tela não respondeu ({motivo}).",
|
| 1504 |
"cfg.nav.modelos": "Mais modelos",
|
| 1505 |
"cfg.modelos.titulo": "Mais modelos",
|
|
@@ -1508,9 +1510,9 @@
|
|
| 1508 |
"modelinhos.baixando": "Baixando os modelinhos de foto (~300 MB): tirar o fundo, aumentar a resolução, selecionar por clique e remover um objeto.",
|
| 1509 |
"modelinhos.pronto": "Os modelinhos de foto estão prontos.",
|
| 1510 |
"modelinhos.erro": "Não consegui baixar os modelinhos de foto ({erro}). Tento de novo na próxima abertura.",
|
| 1511 |
-
"lite.baixando": "Baixando o Gargantua 2 Lite (
|
| 1512 |
-
"lite.pronto": "O Gargantua 2 Lite está pronto.",
|
| 1513 |
-
"lite.erro": "Não consegui baixar o Gargantua 2 Lite ({erro}). Tento de novo na próxima abertura.",
|
| 1514 |
"cfg.bonsai.sub": "Gera imagens a partir de uma frase (\"desenhe um farol ao entardecer\"). Da PrismML, Apache 2.0. Opcional: {tamanho} e uma placa NVIDIA com 6 GB (ou um Mac com Apple Silicon). Roda sem internet depois de instalado.",
|
| 1515 |
"cfg.bonsai.instalar": "Instalar ({tamanho})",
|
| 1516 |
"cfg.bonsai.remover": "Remover",
|
|
@@ -1591,6 +1593,11 @@
|
|
| 1591 |
"acoes.resultado.resumir": "Resumo",
|
| 1592 |
"acoes.resultado.traduzir": "Tradução",
|
| 1593 |
"acoes.resultado.perguntar": "Resposta",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1594 |
"acoes.pergunta_dica": "Pergunte algo sobre o trecho",
|
| 1595 |
"acoes.enviar_pergunta": "Enviar pergunta",
|
| 1596 |
"acoes.substituir": "Substituir no texto",
|
|
@@ -1601,6 +1608,7 @@
|
|
| 1601 |
"acoes.atalho_linux": "Ctrl+Alt+R",
|
| 1602 |
"acoes.atalho_windows": "Ctrl+Alt+R",
|
| 1603 |
"acoes.atalho_macos": "Ctrl+Option+R",
|
|
|
|
| 1604 |
"acoes.trecho": "Texto selecionado: \"{trecho}\"",
|
| 1605 |
"acoes.cortado": "(só os primeiros {n} caracteres entram)",
|
| 1606 |
"acoes.sem_texto": "Nenhum texto selecionado. Marque um trecho em qualquer programa e aperte o atalho de novo.",
|
|
@@ -1625,6 +1633,34 @@
|
|
| 1625 |
"cfg.assist.sistema_titulo": "Iniciar com o sistema",
|
| 1626 |
"cfg.assist.sistema": "Abrir o openTARS escondido quando eu entrar no computador",
|
| 1627 |
"cfg.assist.sistema_nota": "Ligado por padrão: o atalho responde na hora desde o começo do dia e o ícone fica perto do relógio. Desmarque para o openTARS não abrir com o sistema.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1628 |
"cfg.assist.acoes_titulo": "Ações de texto",
|
| 1629 |
"cfg.assist.acoes_sub": "Reescrever, resumir, traduzir e perguntar sobre o trecho. O texto selecionado é só material: nada do que está escrito nele é executado, e nada sai do seu computador.",
|
| 1630 |
"cfg.assist.acoes_modelo": "Modelo das ações: {modelo}.",
|
|
@@ -1721,6 +1757,12 @@
|
|
| 1721 |
"ctx.nota": "Só olho o nome do programa em uso e o título da janela. Nada é gravado nem enviado. Desligue em Configurações > Assistente.",
|
| 1722 |
"ctx.selecione": "Selecione o texto que quer revisar e clique em Revisar de novo (ou use o atalho das ações de texto).",
|
| 1723 |
"cfg.assist.ctx_titulo": "Sugestões pelo programa em uso",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1724 |
"cfg.assist.ctx": "Sugerir ajuda conforme o programa em uso (por exemplo, revisar um e-mail)",
|
| 1725 |
"ctx.nota_config": "Ligado por padrão. O openTARS olha a cada 4 segundos só o nome do programa e o título da janela em uso, sem gravar nem enviar nada, e sugere uma vez por janela.",
|
| 1726 |
"arq.acao.foto": "Editar a foto",
|
|
@@ -1763,5 +1805,84 @@
|
|
| 1763 |
"foto.salvar": "Salvar cópia: {nome}",
|
| 1764 |
"foto.salvar_n": "Salvar {n} cópias",
|
| 1765 |
"foto.aviso": "Sempre uma cópia na mesma pasta: a original fica como está. A cópia sai sem os dados de localização e do aparelho. Cortar, girar e clarear são na hora; tirar o fundo, aumentar e remover um objeto usam modelinhos neste computador e levam alguns segundos.",
|
| 1766 |
-
"foto.salvas": "Cópia salva: {nomes}."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1767 |
}
|
|
|
|
| 504 |
"term.modo_auto_explica": "o openTARS escolhe sozinho a IA mais adequada pra cada tarefa.",
|
| 505 |
"term.voce": "Você",
|
| 506 |
"term.conversa_carregada": "conversa anterior: {n} pedido(s) · /limpar começa do zero",
|
| 507 |
+
"term.ajuda": "openTARS {versao} · assistente de IA local\n\nConversa\n opentars abre a conversa no terminal (continua a última)\n opentars --new começa uma conversa nova\n opentars --resume escolhe uma conversa salva para continuar\n opentars --plan modo plano: a IA só descreve o que faria\n opentars --auto edições nesta pasta sem perguntar (apagar, enviar e instalar ainda perguntam)\n opentars --chat \"pedido\" responde um pedido e sai\n opentars -p \"pedido\" para scripts: só a resposta (ou --output-format json); lê o stdin\n opentars-gui abre a janela\n opentars-gui --rapido abre a barra rápida (ctrl+alt+espaço)\n\nModelos\n opentars --model <nome> usa este modelo no -p\n opentars --explicar \"pedido\" mostra como a tarefa e o modelo são escolhidos\n opentars --avaliar-classificador mede o acerto da Murph e do modo automático\n\nAmbiente\n opentars --diagnostico confere o motor, a tela e a acessibilidade (não muda nada)\n opentars --autoteste diagnóstico + um teste real com a calculadora\n opentars --setup instala o que estiver faltando (motor, modelos...)\n opentars --missao-vm \"tarefa\" roda uma missão de agente numa máquina virtual descartável (precisa do modelo Pro)\n\nPreferências\n opentars --idioma <código> troca o idioma (pt_BR ou en)\n opentars --atalho [teclas|off] mostra, troca ou remove o atalho global\n opentars --version mostra a versão\n\nDentro da conversa, /ajuda lista os comandos. Um AGENTS.md na pasta entra em toda conversa.",
|
| 508 |
"idioma.lista_titulo": "Idiomas disponíveis:",
|
| 509 |
"idioma.como_trocar": "Troque com: /idioma <código> (ex: /idioma en)",
|
| 510 |
"idioma.trocado": "Idioma: {nome}",
|
|
|
|
| 755 |
"gargantua.desligado": "{modelo} instalado, mas TARS_SEM_GARGANTUA=1: o AUTO não dá preferência pra ele",
|
| 756 |
"gargantua.nao_instalado": "Gargantua não instalado. Pra baixar a IA oficial do openTARS: ollama pull {modelo}",
|
| 757 |
"setup.gargantua_ja": "Gargantua, a IA oficial do openTARS, já está instalado ({modelo}).",
|
| 758 |
+
"setup.gargantua_baixando": "Baixando o Gargantua, a IA oficial do openTARS ({modelo}, ~6,4 GB)...",
|
| 759 |
+
"setup.gargantua_segundo_plano": "Sem terminal aqui para mostrar o andamento (instalação pela loja de aplicativos ou automática): o download do Gargantua (~6,4 GB) continua em segundo plano. O openTARS mostra o progresso quando você abrir.",
|
| 760 |
"setup.gargantua_pulado": "TARS_SEM_GARGANTUA=1: o Gargantua não foi baixado.",
|
| 761 |
"msg.folego": "tarefa longa: {etapas} etapas, {acoes} ações feitas; continuando",
|
| 762 |
"versao.nova": "Saiu o openTARS {versao}! Pra atualizar, rode de novo o comando de instalação (está no README do GitHub).",
|
|
|
|
| 785 |
"pu.slogan": "Seu assistente que mexe no computador, sem sair dele.",
|
| 786 |
"pu.passo": "Passo {n} de {total}",
|
| 787 |
"pu.passo.idioma": "Idioma",
|
| 788 |
+
"pu.passo.ollama": "Motor de IA",
|
| 789 |
"pu.passo.nuvem": "Nuvem (opcional)",
|
| 790 |
"pu.passo.gargantua": "Baixar os modelos",
|
| 791 |
"pu.passo.teste": "Teste rápido",
|
|
|
|
| 794 |
"pu.pausar": "Pausar",
|
| 795 |
"pu.retomar": "Retomar",
|
| 796 |
"pu.tentar_de_novo": "Tentar de novo",
|
| 797 |
+
"pu.ollama.titulo": "Preparando o motor de IA",
|
| 798 |
+
"pu.ollama.texto": "O motor roda o modelo de IA no seu computador. O openTARS usa o llama.cpp; se você já tem o Ollama, ele também serve.",
|
| 799 |
+
"pu.ollama.procurando": "Procurando um motor de IA neste computador…",
|
| 800 |
+
"pu.ollama.ok": "Motor de IA pronto ({versao})",
|
| 801 |
"pu.ollama.parado": "O Ollama está instalado, mas parado.",
|
| 802 |
+
"pu.ollama.faltando": "Falta o motor de IA. São uns 35 MB, sem pedir senha.",
|
| 803 |
+
"pu.ollama.instalar": "Instalar o motor",
|
| 804 |
"pu.ollama.iniciar": "Iniciar o Ollama",
|
| 805 |
"pu.ollama.procurar_de_novo": "Procurar de novo",
|
| 806 |
+
"pu.ollama.instalando": "Baixando o motor (llama.cpp)…",
|
| 807 |
"pu.ollama.iniciando": "Iniciando o Ollama…",
|
| 808 |
"pu.ollama.erro_instalar": "Não consegui instalar: {motivo}",
|
| 809 |
"pu.ollama.abri_download": "Abri a página de download do Ollama no navegador. Instale, abra o Ollama e clique em Procurar de novo.",
|
|
|
|
| 1051 |
"cfg.voz.instalada": "Voz instalada.",
|
| 1052 |
"cfg.voz.erro": "Não deu pra instalar: {erro}",
|
| 1053 |
"cfg.voz.fala": "Fala",
|
| 1054 |
+
"cfg.voz.resposta_completa": "Ler as respostas longas inteiras (por padrão só as primeiras frases são faladas; o resto fica na tela)",
|
| 1055 |
"cfg.voz.responder_falando": "Responder falando",
|
| 1056 |
"cfg.voz.voz": "Voz",
|
| 1057 |
"cfg.voz.velocidade": "Velocidade",
|
|
|
|
| 1195 |
"mascote.dica8": "Tudo roda no seu computador: nada do que você escreve sai daqui, a menos que você ligue a nuvem.",
|
| 1196 |
"mascote.dica9": "Em Configurações dá pra criar rotinas: o TARS faz uma tarefa sozinho no horário que você escolher.",
|
| 1197 |
"mascote.dica10": "Escreva /modelo auto pro TARS escolher o melhor modelo pra cada pedido.",
|
| 1198 |
+
"term.slogan": "assistente de IA local",
|
| 1199 |
"term.bem_vindo": "Bem-vindo ao openTARS!",
|
| 1200 |
"term.rotulo_ollama": "ollama:",
|
| 1201 |
+
"term.rotulo_motor": "motor:",
|
| 1202 |
+
"term.rotulo_pasta": "pasta:",
|
| 1203 |
+
"term.motor_offline": "fora do ar ({host}) · reinicie o openTARS para ligar o motor",
|
| 1204 |
+
"cli.regua.perguntar": "perguntar",
|
| 1205 |
+
"cli.regua.plano": "plano",
|
| 1206 |
+
"cli.regua.automatico": "automático",
|
| 1207 |
"term.rotulo_modelo": "modelo:",
|
| 1208 |
"term.ollama_online": "conectado ({host})",
|
| 1209 |
"term.ollama_offline": "desligado ({host}) · inicie com: sudo systemctl start ollama",
|
|
|
|
| 1212 |
"term.modo_fixo": "fixo",
|
| 1213 |
"term.parar": "ctrl+c para parar",
|
| 1214 |
"term.ate_logo": "Até mais.",
|
|
|
|
|
|
|
| 1215 |
"modo.base.nome": "Base",
|
| 1216 |
+
"modo.base.desc": "Tudo para o dia a dia: histórico, memória, voz, rotinas, Probes e nuvem.",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1217 |
"cfg.nav.avancado": "Ajustes avançados",
|
| 1218 |
"cfg.nav.diagnostico": "Diagnóstico",
|
| 1219 |
"cfg.avancado.titulo": "Ajustes avançados",
|
| 1220 |
+
"cfg.avancado.sub": "Só valem com o modo dev ligado. Mudar estes valores pode deixar a IA pior: o Gargantua foi treinado com os padrões.",
|
| 1221 |
"cfg.av.automatico": "Automático",
|
| 1222 |
"cfg.av.temperatura": "Temperatura",
|
| 1223 |
"cfg.av.temperatura_sub": "Quanto a resposta varia. Mais baixa é mais previsível (o padrão é 0,1).",
|
|
|
|
| 1228 |
"cfg.av.ferramentas": "Ferramentas",
|
| 1229 |
"cfg.av.ferramentas_sub": "Desmarque o que a IA não pode usar. Ela ainda vê a ferramenta, mas o openTARS recusa.",
|
| 1230 |
"cfg.av.salvar": "Salvar",
|
| 1231 |
+
"cfg.av.motor": "Motor de IA",
|
| 1232 |
+
"cfg.av.motor_sub": "O llama.cpp é o padrão. Troque para o Ollama se preferir continuar com ele. Vale na próxima vez que o openTARS abrir.",
|
| 1233 |
+
"cfg.av.motor_llamacpp": "llama.cpp (padrão)",
|
| 1234 |
+
"cfg.av.motor_ollama": "Ollama",
|
| 1235 |
+
"cfg.av.motor_reiniciar": "Salvo. Reinicie o openTARS para trocar o motor.",
|
| 1236 |
"cfg.av.salvo": "Salvo.",
|
| 1237 |
"cfg.av.restaurar": "Voltar aos padrões",
|
| 1238 |
+
"cfg.av.so_pro": "O modo dev está desligado: estes ajustes ficam guardados, mas só valem com ele ligado (Configurações > Geral).",
|
| 1239 |
"cfg.diag.titulo": "Diagnóstico",
|
| 1240 |
"cfg.diag.sub": "O que o openTARS fez e se o ambiente está em ordem. Nada daqui sai do seu computador.",
|
| 1241 |
"cfg.diag.log": "Ferramentas usadas",
|
|
|
|
| 1246 |
"cfg.diag.ambiente_sub": "Ollama, modelos, tela, microfone e permissões, o mesmo do comando opentars --diagnostico.",
|
| 1247 |
"cfg.diag.conferir": "Conferir agora",
|
| 1248 |
"cfg.diag.conferindo": "Conferindo…",
|
|
|
|
|
|
|
|
|
|
| 1249 |
"pu.garg.nome_lite": "Gargantua Lite",
|
| 1250 |
+
"pu.garg.lite_nota": "Este computador não tem placa de vídeo com 4 GB e tem pouca memória: baixamos o Gargantua 2.5 Lite (4,4 GB, com visão e áudio), que é mais leve. Ele lê a tela, mas não aponta botões pequenos pela posição; o clique pelo nome funciona pela acessibilidade do app.",
|
| 1251 |
"cmd.sempre": [
|
| 1252 |
"sempre",
|
| 1253 |
"a"
|
|
|
|
| 1285 |
"probes.titulo": "Probes",
|
| 1286 |
"probes.sub": "Pequenos vigias que rodam em segundo plano, mesmo com a janela fechada, e avisam quando algo importa. Eles só olham: nada muda no seu computador sem você escolher.",
|
| 1287 |
"probes.servico": "Ligar e desligar",
|
| 1288 |
+
"probes.baixar_lite": "Baixar o Gargantua 2.5 Lite (4,4 GB, com visão e áudio)",
|
| 1289 |
"probes.parar_tudo": "Parar tudo",
|
| 1290 |
"probes.nova": "Criar um Probe",
|
| 1291 |
"probes.nova_sub": "Três passos: escolha o que vigiar, preencha o que pedir e confira a frase do que ele vai fazer.",
|
|
|
|
| 1493 |
"probes.pp.missao": "Avisa quando {quando}.",
|
| 1494 |
"avisos.dica_sino": "Avisos dos Probes: {n} novo(s)",
|
| 1495 |
"pu.ollama_antigo": "Este modelo pede um Ollama mais novo. Atualize com: curl -fsSL https://ollama.com/install.sh | sh (e tente de novo).",
|
| 1496 |
+
"cfg.gargantua_novo": "Você tem o Gargantua 1 ou um modelo mais antigo. O Gargantua 2.5 é a IA principal do openTARS, em Gemma 4: 6,3 GB, com visão e suporte a áudio. Vale baixar.",
|
| 1497 |
+
"cfg.baixar_gargantua2": "Baixar o Gargantua 2.5",
|
|
|
|
| 1498 |
"msg.busca_nao_lida": "A busca foi aberta no navegador, mas a IA não leu a página: o que ela respondeu acima vem da memória dela e pode estar desatualizado. Confira os resultados no navegador.",
|
| 1499 |
"msg.pouca_confianca": "Não tenho certeza dessa resposta: ela veio da memória do modelo e pode estar errada ou inventada. Confira numa fonte antes de usar.",
|
| 1500 |
"msg.verificando_fato": "Pouca confiança na resposta acima: vou conferir pela busca na web.",
|
| 1501 |
"msg.busca_nada_aberto": "Nada foi aberto no seu computador: a resposta veio do SearXNG. Se ela diz que abriu algo no navegador, isso não aconteceu.",
|
| 1502 |
"visao.ok": "{modelo} (olha a tela: acha botões e descreve janelas)",
|
| 1503 |
+
"visao.nao_instalado": "não instalado. Para o openTARS enxergar a tela, baixe o Gargantua 2.5: {comando}",
|
| 1504 |
+
"visao.desligado": "{modelo} está instalado, mas desligado (TARS_VISAO=0).",
|
| 1505 |
"visao.sem_resposta": "O modelo que olha a tela não respondeu ({motivo}).",
|
| 1506 |
"cfg.nav.modelos": "Mais modelos",
|
| 1507 |
"cfg.modelos.titulo": "Mais modelos",
|
|
|
|
| 1510 |
"modelinhos.baixando": "Baixando os modelinhos de foto (~300 MB): tirar o fundo, aumentar a resolução, selecionar por clique e remover um objeto.",
|
| 1511 |
"modelinhos.pronto": "Os modelinhos de foto estão prontos.",
|
| 1512 |
"modelinhos.erro": "Não consegui baixar os modelinhos de foto ({erro}). Tento de novo na próxima abertura.",
|
| 1513 |
+
"lite.baixando": "Baixando o Gargantua 2.5 Lite (4,4 GB), a IA que fica em segundo plano para o assistente, as ações de texto e os Probes.",
|
| 1514 |
+
"lite.pronto": "O Gargantua 2.5 Lite está pronto.",
|
| 1515 |
+
"lite.erro": "Não consegui baixar o Gargantua 2.5 Lite ({erro}). Tento de novo na próxima abertura.",
|
| 1516 |
"cfg.bonsai.sub": "Gera imagens a partir de uma frase (\"desenhe um farol ao entardecer\"). Da PrismML, Apache 2.0. Opcional: {tamanho} e uma placa NVIDIA com 6 GB (ou um Mac com Apple Silicon). Roda sem internet depois de instalado.",
|
| 1517 |
"cfg.bonsai.instalar": "Instalar ({tamanho})",
|
| 1518 |
"cfg.bonsai.remover": "Remover",
|
|
|
|
| 1593 |
"acoes.resultado.resumir": "Resumo",
|
| 1594 |
"acoes.resultado.traduzir": "Tradução",
|
| 1595 |
"acoes.resultado.perguntar": "Resposta",
|
| 1596 |
+
"acoes.aba.responder": "Responder",
|
| 1597 |
+
"acoes.opcao.curto": "Mais curto",
|
| 1598 |
+
"acoes.opcao.lista": "Em tópicos",
|
| 1599 |
+
"acoes.resultado.responder": "Rascunho da resposta",
|
| 1600 |
+
"acoes.responder_dica": "O que você quer dizer? (ex.: topo amanhã)",
|
| 1601 |
"acoes.pergunta_dica": "Pergunte algo sobre o trecho",
|
| 1602 |
"acoes.enviar_pergunta": "Enviar pergunta",
|
| 1603 |
"acoes.substituir": "Substituir no texto",
|
|
|
|
| 1608 |
"acoes.atalho_linux": "Ctrl+Alt+R",
|
| 1609 |
"acoes.atalho_windows": "Ctrl+Alt+R",
|
| 1610 |
"acoes.atalho_macos": "Ctrl+Option+R",
|
| 1611 |
+
"acoes.da_janela": "Texto lido da janela: {janela}",
|
| 1612 |
"acoes.trecho": "Texto selecionado: \"{trecho}\"",
|
| 1613 |
"acoes.cortado": "(só os primeiros {n} caracteres entram)",
|
| 1614 |
"acoes.sem_texto": "Nenhum texto selecionado. Marque um trecho em qualquer programa e aperte o atalho de novo.",
|
|
|
|
| 1633 |
"cfg.assist.sistema_titulo": "Iniciar com o sistema",
|
| 1634 |
"cfg.assist.sistema": "Abrir o openTARS escondido quando eu entrar no computador",
|
| 1635 |
"cfg.assist.sistema_nota": "Ligado por padrão: o atalho responde na hora desde o começo do dia e o ícone fica perto do relógio. Desmarque para o openTARS não abrir com o sistema.",
|
| 1636 |
+
"cfg.assist.agenda_titulo": "Agenda",
|
| 1637 |
+
"cfg.assist.agenda_sub": "Pergunte \"o que tenho amanhã?\". Aponte um arquivo .ics ou o link de um calendário (Google, Outlook e Apple exportam). Só leitura; desligada até você configurar.",
|
| 1638 |
+
"cfg.assist.agenda_dica": "/caminho/agenda.ics ou https://… (vários separados por ;)",
|
| 1639 |
+
"cfg.assist.agenda_ligada": "Ligada: lendo {n} calendário(s). O openTARS nunca altera o seu calendário.",
|
| 1640 |
+
"cfg.assist.agenda_nao_achei": "Não achei {fonte}: use um arquivo que existe ou um link que comece com https://",
|
| 1641 |
+
"cfg.assist.desligada": "Desligada.",
|
| 1642 |
+
"cfg.assist.notas_titulo": "Notas",
|
| 1643 |
+
"cfg.assist.notas_sub": "Diga \"cria uma nota: …\" ou \"minhas notas sobre …\". As notas são arquivos Markdown na pasta que você escolher; desligadas até você configurar.",
|
| 1644 |
+
"cfg.assist.notas_dica": "/caminho/Notas (pastas do Obsidian e do Joplin servem)",
|
| 1645 |
+
"cfg.assist.notas_ligada": "Ligadas: as notas são salvas e procuradas nesta pasta. Uma nota nunca é escrita por cima de outra.",
|
| 1646 |
+
"cfg.assist.salvar": "Salvar",
|
| 1647 |
+
"cfg.assist.email_titulo": "E-mail",
|
| 1648 |
+
"cfg.assist.email_sub": "Pergunte \"que e-mails chegaram hoje?\", \"lê o último e-mail da Ana\" ou \"envia um e-mail para ana@exemplo.com dizendo…\". Ler nunca marca nada como lido; enviar sempre pergunta antes. Desligado até você configurar.",
|
| 1649 |
+
"cfg.assist.email_usuario": "Endereço de e-mail",
|
| 1650 |
+
"cfg.assist.email_senha": "Senha (Gmail e Outlook pedem uma senha de app)",
|
| 1651 |
+
"cfg.assist.email_senha_guardada": "Senha guardada no cofre do sistema (digite outra para trocar)",
|
| 1652 |
+
"cfg.assist.email_imap": "Servidor de entrada (IMAP); vazio para Gmail, Outlook, iCloud e Yahoo",
|
| 1653 |
+
"cfg.assist.email_smtp": "Servidor de saída (SMTP); vazio para os mesmos",
|
| 1654 |
+
"cfg.assist.email_ligado": "Ligado: {usuario}. A senha fica no cofre do sistema, nunca em arquivo.",
|
| 1655 |
+
"cfg.assist.email_servidores": "Não conheço este provedor: preencha os servidores IMAP e SMTP.",
|
| 1656 |
+
"cfg.assist.email_sem_senha": "Digite a senha.",
|
| 1657 |
+
"cfg.assist.email_sem_cofre": "Este computador não tem cofre de senhas para o openTARS (instale o pacote keyring). Não guardei nada.",
|
| 1658 |
+
"cfg.assist.email_testar": "Testar",
|
| 1659 |
+
"cfg.assist.email_desligar": "Desligar e esquecer",
|
| 1660 |
+
"cfg.assist.email_testando": "Testando…",
|
| 1661 |
+
"cfg.assist.email_ok": "Conectou. Nada foi alterado na sua caixa.",
|
| 1662 |
+
"cfg.assist.email_erro": "Não deu: {erro}",
|
| 1663 |
+
"cfg.assist.email_salvar_antes": "Salve a conta primeiro.",
|
| 1664 |
"cfg.assist.acoes_titulo": "Ações de texto",
|
| 1665 |
"cfg.assist.acoes_sub": "Reescrever, resumir, traduzir e perguntar sobre o trecho. O texto selecionado é só material: nada do que está escrito nele é executado, e nada sai do seu computador.",
|
| 1666 |
"cfg.assist.acoes_modelo": "Modelo das ações: {modelo}.",
|
|
|
|
| 1757 |
"ctx.nota": "Só olho o nome do programa em uso e o título da janela. Nada é gravado nem enviado. Desligue em Configurações > Assistente.",
|
| 1758 |
"ctx.selecione": "Selecione o texto que quer revisar e clique em Revisar de novo (ou use o atalho das ações de texto).",
|
| 1759 |
"cfg.assist.ctx_titulo": "Sugestões pelo programa em uso",
|
| 1760 |
+
"cfg.assist.indice_titulo": "Busca de arquivos",
|
| 1761 |
+
"cfg.assist.indice": "Indexar meus arquivos para a busca ser instantânea (desligado por padrão)",
|
| 1762 |
+
"cfg.assist.indice_nota": "O índice guarda só neste computador os nomes dos arquivos e o começo de cada texto, nunca envia nada e é apagado quando você desliga.",
|
| 1763 |
+
"cfg.assist.indice_montando": "Montando o índice em segundo plano…",
|
| 1764 |
+
"cfg.assist.indice_pronto": "Índice pronto: {n} arquivos.",
|
| 1765 |
+
"cfg.assist.indice_erro": "O índice falhou: {erro}",
|
| 1766 |
"cfg.assist.ctx": "Sugerir ajuda conforme o programa em uso (por exemplo, revisar um e-mail)",
|
| 1767 |
"ctx.nota_config": "Ligado por padrão. O openTARS olha a cada 4 segundos só o nome do programa e o título da janela em uso, sem gravar nem enviar nada, e sugere uma vez por janela.",
|
| 1768 |
"arq.acao.foto": "Editar a foto",
|
|
|
|
| 1805 |
"foto.salvar": "Salvar cópia: {nome}",
|
| 1806 |
"foto.salvar_n": "Salvar {n} cópias",
|
| 1807 |
"foto.aviso": "Sempre uma cópia na mesma pasta: a original fica como está. A cópia sai sem os dados de localização e do aparelho. Cortar, girar e clarear são na hora; tirar o fundo, aumentar e remover um objeto usam modelinhos neste computador e levam alguns segundos.",
|
| 1808 |
+
"foto.salvas": "Cópia salva: {nomes}.",
|
| 1809 |
+
"cfg.dev": "Modo dev",
|
| 1810 |
+
"cfg.dev_sub": "Para quem quer mexer no que fica no automático.",
|
| 1811 |
+
"cfg.dev_ligar": "Mostrar os ajustes avançados e o diagnóstico",
|
| 1812 |
+
"modo.dev.nome": "Dev",
|
| 1813 |
+
"modo.dev.desc": "Tudo do Base, mais ajustes avançados e diagnóstico.",
|
| 1814 |
+
"motor.preparando": "Preparando o motor novo (llama.cpp) em segundo plano. Pode continuar usando o openTARS.",
|
| 1815 |
+
"motor.pronto": "Motor novo pronto: o openTARS agora roda no llama.cpp.",
|
| 1816 |
+
"motor.erro": "Não deu para preparar o motor novo ({erro}). O openTARS continua no Ollama.",
|
| 1817 |
+
"cfg.modelos.busca_ph": "Pesquisar modelos (nome, visão, ferramentas...)",
|
| 1818 |
+
"cfg.modelos.busca_web": "Procurar na biblioteca do Ollama",
|
| 1819 |
+
"cfg.modelos.buscando": "Procurando na biblioteca do Ollama...",
|
| 1820 |
+
"cfg.modelos.busca_nada": "Nada encontrado na biblioteca do Ollama para essa pesquisa.",
|
| 1821 |
+
"cfg.modelos.busca_erro": "Não deu para acessar a biblioteca do Ollama. Confira a conexão e tente de novo.",
|
| 1822 |
+
"cfg.modelos.g.web": "Da biblioteca do Ollama",
|
| 1823 |
+
"cfg.modelos.g.web_sub": "Resultados do ollama.com. O tamanho aparece ao clicar: ele é conferido antes de começar o download.",
|
| 1824 |
+
"cfg.modelos.calculando": "Conferindo o tamanho do download...",
|
| 1825 |
+
"cfg.assist.reabrir_titulo": "Apps que escondem os botões",
|
| 1826 |
+
"cfg.assist.reabrir": "Deixar o openTARS reabrir um app para conseguir clicar nos botões dele",
|
| 1827 |
+
"cfg.assist.reabrir_nota": "Apps baseados no Chrome que já estavam abertos (Claude, Discord, VS Code, Slack, Chrome) escondem os botões de outros programas. Com isto ligado, quando o openTARS precisar clicar em um deles, fecha o app e abre de novo com a acessibilidade ligada. O que não foi salvo nesse app pode se perder, e o app nunca é forçado a fechar: se ele recusar, continua aberto. Desligado por padrão.",
|
| 1828 |
+
"vm.cli.uso": "Uso: opentars --missao-vm \"o que o agente deve fazer dentro da máquina virtual\"",
|
| 1829 |
+
"vm.cli.falta": "A máquina virtual não está pronta: falta {falta}.",
|
| 1830 |
+
"vm.cli.sem_base": "A imagem da máquina virtual ainda não foi montada.",
|
| 1831 |
+
"vm.cli.sem_pro": "Isto precisa do modelo Gargantua Pro ({modelo}). Adicione em Configurações > Mais modelos.",
|
| 1832 |
+
"vm.cli.comecando": "Começando numa máquina virtual descartável: {pedido}",
|
| 1833 |
+
"vm.cli.resultado": "Terminou ({motivo}, {segundos} s): {resultado}",
|
| 1834 |
+
"vm.cli.acesso": " site visitado: {acesso}",
|
| 1835 |
+
"vm.cli.arquivos": "O agente deixou {n} arquivo(s) na pasta de saída: {lista}",
|
| 1836 |
+
"vm.cli.liberar": "Copiar para {pasta}? [s/N] ",
|
| 1837 |
+
"vm.cli.descartados": "Não copiados. Foram apagados com a máquina virtual.",
|
| 1838 |
+
"vm.secao": "Missões numa máquina virtual",
|
| 1839 |
+
"vm.secao_sub": "O Gargantua Pro trabalha num computador isolado e descartável: abre programas, navega e escreve. Nada do seu PC fica visível lá dentro, e o que ele produzir só chega aqui se você aprovar.",
|
| 1840 |
+
"vm.abrir": "Abrir a janela da VM",
|
| 1841 |
+
"vm.so_linux": "Por enquanto, as missões em VM funcionam só no Linux com KVM.",
|
| 1842 |
+
"vm.janela.titulo": "Missão numa máquina virtual",
|
| 1843 |
+
"vm.janela.sub": "Escreva o que o agente deve fazer. Ele usa o mouse e o teclado de uma VM descartável; ao terminar, a VM é apagada.",
|
| 1844 |
+
"vm.janela.falta": "A máquina virtual não está pronta: falta {falta}.",
|
| 1845 |
+
"vm.janela.sem_base": "A imagem da VM ainda não existe. Montar leva uns 5 minutos e ocupa cerca de 1,6 GB.",
|
| 1846 |
+
"vm.janela.sem_pro": "As missões em VM usam o Gargantua Pro (cerca de 7,6 GB).",
|
| 1847 |
+
"vm.janela.conferindo": "Conferindo o modelo...",
|
| 1848 |
+
"vm.janela.montar": "Montar a imagem",
|
| 1849 |
+
"vm.janela.montando": "Montando a imagem da VM...",
|
| 1850 |
+
"vm.janela.montando_pct": "Baixando o sistema da VM: {pct}%",
|
| 1851 |
+
"vm.janela.baixar_pro": "Baixar o Gargantua Pro",
|
| 1852 |
+
"vm.janela.baixando": "Baixando o Gargantua Pro...",
|
| 1853 |
+
"vm.janela.erro": "Não deu certo: {erro}",
|
| 1854 |
+
"vm.janela.pedido_ph": "Ex.: abra o terminal e crie o arquivo notas.txt na pasta de saída com a lista de compras",
|
| 1855 |
+
"vm.janela.comecar": "Começar",
|
| 1856 |
+
"vm.janela.parar": "Parar",
|
| 1857 |
+
"vm.janela.internet": "Internet (cada site fica registrado)",
|
| 1858 |
+
"vm.janela.sem_tela": "A tela da VM aparece aqui.",
|
| 1859 |
+
"vm.janela.tela": "Tela da máquina virtual",
|
| 1860 |
+
"vm.janela.passos": "Passos",
|
| 1861 |
+
"vm.janela.sites": "Sites visitados",
|
| 1862 |
+
"vm.janela.ligando": "Ligando a VM...",
|
| 1863 |
+
"vm.janela.trabalhando": "O agente está trabalhando.",
|
| 1864 |
+
"vm.janela.parando": "Parando...",
|
| 1865 |
+
"vm.janela.fim": "Terminou ({motivo}, {segundos} s): {resultado}",
|
| 1866 |
+
"vm.janela.arquivos": "O agente deixou {n} arquivo(s) na pasta de saída. Marque o que deve vir para o seu PC:",
|
| 1867 |
+
"vm.janela.trazer": "Trazer para {pasta}",
|
| 1868 |
+
"vm.janela.descartar": "Descartar tudo",
|
| 1869 |
+
"vm.janela.trazidos": "{n} arquivo(s) copiado(s) para {pasta}. O resto foi apagado.",
|
| 1870 |
+
"vm.janela.descartados": "Nada foi copiado. Os arquivos foram apagados com a VM.",
|
| 1871 |
+
"vm.motivo.terminou": "concluída",
|
| 1872 |
+
"vm.motivo.cancelada": "parada por você",
|
| 1873 |
+
"vm.motivo.tempo": "tempo esgotado",
|
| 1874 |
+
"vm.motivo.passos": "limite de passos",
|
| 1875 |
+
"vm.passo.clicar": "clicou em {detalhe}",
|
| 1876 |
+
"vm.passo.duplo": "clique duplo em {detalhe}",
|
| 1877 |
+
"vm.passo.direito": "clique direito em {detalhe}",
|
| 1878 |
+
"vm.passo.atalho": "apertou {detalhe}",
|
| 1879 |
+
"vm.passo.digitou": "digitou um texto",
|
| 1880 |
+
"vm.passo.rolou": "rolou a tela",
|
| 1881 |
+
"vm.passo.esperou": "esperou",
|
| 1882 |
+
"cli.barra_dica": "/ajuda · ctrl+c para",
|
| 1883 |
+
"jp.atualizando_script": "Atualizando pelo instalador... o openTARS reabre sozinho quando terminar.",
|
| 1884 |
+
"jp.erro_script": "A atualização não terminou. O registro está em {arquivo}.",
|
| 1885 |
+
"setup.motor_llamacpp": "Motor: llama.cpp. O openTARS baixa o motor e o Gargantua 2.5 na primeira abertura, com barra e sem pedir senha.",
|
| 1886 |
+
"g25.oferta": "Chegou o Gargantua 2.5, a IA nova do openTARS: vê a tela e entende a sua voz.",
|
| 1887 |
+
"g25.ver": "Baixar o Gargantua 2.5"
|
| 1888 |
}
|
app/nucleo/aplicacoes.py
CHANGED
|
@@ -820,6 +820,44 @@ def _eh_electron(comando):
|
|
| 820 |
return False
|
| 821 |
|
| 822 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 823 |
# Nomes que são SITES, não programas (18/10: open_application("google") abria o Google Chrome, que só tem "google" no
|
| 824 |
# nome). Só vira site quando não há um programa com exatamente esse nome: um Spotify ou Discord instalado continua app.
|
| 825 |
_SITES_NAO_APPS = frozenset({"google", "youtube", "gmail", "wikipedia", "reddit", "amazon", "github", "facebook",
|
|
|
|
| 820 |
return False
|
| 821 |
|
| 822 |
|
| 823 |
+
def comando_com_acessibilidade(argv):
|
| 824 |
+
"""O mesmo comando com a opção que faz o Chromium/Electron montar a árvore de acessibilidade (sem repetir se já tem)."""
|
| 825 |
+
argv = list(argv)
|
| 826 |
+
return argv if OPCAO_ACESSIBILIDADE_CHROMIUM in argv else argv[:1] + [OPCAO_ACESSIBILIDADE_CHROMIUM] + argv[1:]
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
def reabrir_com_acessibilidade(pid, esperar=10):
|
| 830 |
+
"""11/11 (F11): um app Electron/Chromium que já estava aberto não expõe seus botões (medido: ligar a acessibilidade da sessão,
|
| 831 |
+
registrar ouvinte AT-SPI e ligar ScreenReaderEnabled não acordam um app aberto). O único caminho que funciona é abrir de novo
|
| 832 |
+
com a opção. Fecha o app com SIGTERM (nunca SIGKILL: se ele não sair, desiste) e abre igual, com a opção.
|
| 833 |
+
Só chamar com a permissão da pessoa (config reabrir_com_acessibilidade). Devolve (ok, mensagem)."""
|
| 834 |
+
import signal
|
| 835 |
+
try:
|
| 836 |
+
argv = [a.decode() for a in open(f"/proc/{pid}/cmdline", "rb").read().split(b"\0") if a]
|
| 837 |
+
pasta = os.readlink(f"/proc/{pid}/cwd")
|
| 838 |
+
ambiente = dict(os.environ)
|
| 839 |
+
except (OSError, UnicodeDecodeError):
|
| 840 |
+
return False, "could not read the app's command line"
|
| 841 |
+
if len(argv) == 1 and " " in argv[0]: # o Chromium reescreve o título do processo: tudo vem numa palavra só
|
| 842 |
+
# shortcut: separa por espaço, então um argumento com espaço no valor quebra; trocar por leitura do `ps -o args` se aparecer
|
| 843 |
+
argv = [os.path.realpath(f"/proc/{pid}/exe")] + argv[0].split(" ")[1:]
|
| 844 |
+
if not argv or not _eh_electron(argv):
|
| 845 |
+
return False, "not an Electron/Chromium app"
|
| 846 |
+
try:
|
| 847 |
+
os.kill(pid, signal.SIGTERM)
|
| 848 |
+
except OSError as erro:
|
| 849 |
+
return False, f"could not close it: {erro}"
|
| 850 |
+
limite = time.time() + esperar
|
| 851 |
+
while time.time() < limite and os.path.exists(f"/proc/{pid}"):
|
| 852 |
+
time.sleep(0.2)
|
| 853 |
+
if os.path.exists(f"/proc/{pid}") and "zombie" not in open(f"/proc/{pid}/status").read().lower():
|
| 854 |
+
return False, "the app did not close (maybe it is asking to save something); it was left open"
|
| 855 |
+
ambiente["ACCESSIBILITY_ENABLED"] = "1"
|
| 856 |
+
subprocess.Popen(comando_com_acessibilidade(argv), cwd=pasta, env=ambiente, stdin=subprocess.DEVNULL,
|
| 857 |
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
| 858 |
+
return True, "reopened with accessibility on"
|
| 859 |
+
|
| 860 |
+
|
| 861 |
# Nomes que são SITES, não programas (18/10: open_application("google") abria o Google Chrome, que só tem "google" no
|
| 862 |
# nome). Só vira site quando não há um programa com exatamente esse nome: um Spotify ou Discord instalado continua app.
|
| 863 |
_SITES_NAO_APPS = frozenset({"google", "youtube", "gmail", "wikipedia", "reddit", "amazon", "github", "facebook",
|
app/nucleo/chat.py
CHANGED
|
@@ -103,7 +103,7 @@ def _print_em_texto(imagem_b64, pedido, modelo, janela=None, arquivo=None):
|
|
| 103 |
f"'{arquivo}' was loaded, but you cannot see images and the vision model "
|
| 104 |
+ ("did not answer this time" if visao_instalada() else "is not installed")
|
| 105 |
+ ". Don't retry. Tell the user plainly that you could not look at the image, and that Gargantua 2 "
|
| 106 |
-
"is the model that lets you see it (ollama pull ProjectOpenTARS/Gargantua-2). "
|
| 107 |
f"The user's request was: \"{pedido}\"."
|
| 108 |
)
|
| 109 |
return (
|
|
@@ -463,6 +463,11 @@ def _mensagens_sem_pensar(mensagens):
|
|
| 463 |
return copia
|
| 464 |
|
| 465 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
def _familia_do_modelo(modelo):
|
| 467 |
"""A família que o Ollama informa ('qwen3', 'qwen35', ...). Lê o catálogo se ainda não foi lido: no modo de modelo fixo
|
| 468 |
(o da prova do Lab) ele não era, a família vinha vazia, o Gargantua em Qwen3.5 ia sem think=false, pensava em vez de
|
|
@@ -490,7 +495,7 @@ def _executar_turno_streaming(modelo, mensagens, tools, pensar=True):
|
|
| 490 |
_carregar_pensa_sem_pedir()
|
| 491 |
# o "/no_think" é truque do Qwen3; o Qwen3.5 não conhece e o lê como parte do pedido (17/10: abriu o site "/no_think" e
|
| 492 |
# tentou "apt install no_think"). Ele obedece ao think=false.
|
| 493 |
-
forcar_nothink = (not pensar) and modelo in _PENSA_MESMO_SEM_PEDIR and _familia_do_modelo(modelo)
|
| 494 |
payload = {
|
| 495 |
"model": modelo,
|
| 496 |
"messages": _mensagens_sem_pensar(mensagens) if forcar_nothink else mensagens,
|
|
@@ -499,7 +504,7 @@ def _executar_turno_streaming(modelo, mensagens, tools, pensar=True):
|
|
| 499 |
"think": bool(pensar),
|
| 500 |
"options": {"temperature": temperatura_conversa(), "num_ctx": contexto_ia()},
|
| 501 |
}
|
| 502 |
-
if eh_gargantua(modelo) and not pensar and _familia_do_modelo(modelo)
|
| 503 |
# Treinado sem raciocínio: com think=false o molde do .gguf abre a resposta com "<think></think>", que no treino só
|
| 504 |
# vinha antes da resposta final em texto, e o modelo respondia sem chamar ferramenta (prova de 14/10/2026).
|
| 505 |
# 17/10: o Gargantua em Qwen3.5 é o contrário: o molde dele põe o <think> vazio também antes das ferramentas e o
|
|
@@ -831,6 +836,7 @@ _PERGUNTA_DE_IDENTIDADE = re.compile(
|
|
| 831 |
r"who (?:are you|made you|created you|built you|developed you|trained you|is your (?:creator|developer|founder|maker))|"
|
| 832 |
r"what(?:'s| is) your name|your (?:name|creator|developer|founder)|who (?:is|are) (?:the )?(?:founder|creator) of opentars|"
|
| 833 |
r"(?:fundador|criador|dono|founder|creator) (?:do |da |of )?opentars)\b")
|
|
|
|
| 834 |
_VERBOS_DE_BUSCA = re.compile(r"\b(?:pesquis\w*|busc\w*|procur\w*|search\w*|find|look up|look for|ache|achar)\b")
|
| 835 |
|
| 836 |
|
|
@@ -860,6 +866,10 @@ def _contexto_do_pedido(texto, tarefa, multi_etapas, retoma=None):
|
|
| 860 |
linhas.append("About you: your name is Gargantua. You were developed by Enzo, the founder of openTARS, and you run "
|
| 861 |
"inside the openTARS app (openTARS is the app, Gargantua is you). Say so in your own words; never "
|
| 862 |
"name another company or model as your maker.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
sites = _sites_citados_na_busca(texto_n)
|
| 864 |
if sites:
|
| 865 |
linhas.append("To search INSIDE a site, call search_web with query = only the search terms and service = the site "
|
|
@@ -1649,6 +1659,17 @@ def _gerar_imagem_pelo_chat(texto):
|
|
| 1649 |
return _responder_sem_modelo(texto, t("imagem.pronta", arquivo=arquivo.name, pasta=arquivo.parent) + f"\n\n")
|
| 1650 |
|
| 1651 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1652 |
def processar_mensagem(texto, anexos=None):
|
| 1653 |
"""Um pedido do usuário do começo ao fim: comandos (/modelo,
|
| 1654 |
/limpar...), escolha do modelo, resposta e salvamento. Usado pelo
|
|
@@ -1684,6 +1705,10 @@ def processar_mensagem(texto, anexos=None):
|
|
| 1684 |
return gerada
|
| 1685 |
import tars_imagem
|
| 1686 |
tars_imagem.parar() # o Bonsai segura a placa; a conversa (Ollama) precisa dela agora
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1687 |
|
| 1688 |
# "use a ferramenta", "faz de verdade", "try again": a tarefa e o modelo saem do pedido anterior, que é o que vai ser feito
|
| 1689 |
retoma = _ultimo_pedido_real() if not anexos and escolha.eh_retomada(normalizar(texto)) else None
|
|
|
|
| 103 |
f"'{arquivo}' was loaded, but you cannot see images and the vision model "
|
| 104 |
+ ("did not answer this time" if visao_instalada() else "is not installed")
|
| 105 |
+ ". Don't retry. Tell the user plainly that you could not look at the image, and that Gargantua 2 "
|
| 106 |
+
"is the model that lets you see it (ollama pull ProjectOpenTARS/Gargantua-2.5). "
|
| 107 |
f"The user's request was: \"{pedido}\"."
|
| 108 |
)
|
| 109 |
return (
|
|
|
|
| 463 |
return copia
|
| 464 |
|
| 465 |
|
| 466 |
+
# Famílias do Gargantua que obedecem ao think=false e não conhecem o "/no_think" do Qwen3: o Qwen3.5 (2 e Lite de hoje) e o
|
| 467 |
+
# Gemma 4 (Gargantua 2.5, 10/11: o molde do Gemma 4 põe o pensamento vazio sozinho quando enable_thinking é falso).
|
| 468 |
+
_FAMILIAS_THINK_FALSE = ("qwen35", "gemma4")
|
| 469 |
+
|
| 470 |
+
|
| 471 |
def _familia_do_modelo(modelo):
|
| 472 |
"""A família que o Ollama informa ('qwen3', 'qwen35', ...). Lê o catálogo se ainda não foi lido: no modo de modelo fixo
|
| 473 |
(o da prova do Lab) ele não era, a família vinha vazia, o Gargantua em Qwen3.5 ia sem think=false, pensava em vez de
|
|
|
|
| 495 |
_carregar_pensa_sem_pedir()
|
| 496 |
# o "/no_think" é truque do Qwen3; o Qwen3.5 não conhece e o lê como parte do pedido (17/10: abriu o site "/no_think" e
|
| 497 |
# tentou "apt install no_think"). Ele obedece ao think=false.
|
| 498 |
+
forcar_nothink = (not pensar) and modelo in _PENSA_MESMO_SEM_PEDIR and _familia_do_modelo(modelo) not in _FAMILIAS_THINK_FALSE
|
| 499 |
payload = {
|
| 500 |
"model": modelo,
|
| 501 |
"messages": _mensagens_sem_pensar(mensagens) if forcar_nothink else mensagens,
|
|
|
|
| 504 |
"think": bool(pensar),
|
| 505 |
"options": {"temperature": temperatura_conversa(), "num_ctx": contexto_ia()},
|
| 506 |
}
|
| 507 |
+
if eh_gargantua(modelo) and not pensar and _familia_do_modelo(modelo) not in _FAMILIAS_THINK_FALSE:
|
| 508 |
# Treinado sem raciocínio: com think=false o molde do .gguf abre a resposta com "<think></think>", que no treino só
|
| 509 |
# vinha antes da resposta final em texto, e o modelo respondia sem chamar ferramenta (prova de 14/10/2026).
|
| 510 |
# 17/10: o Gargantua em Qwen3.5 é o contrário: o molde dele põe o <think> vazio também antes das ferramentas e o
|
|
|
|
| 836 |
r"who (?:are you|made you|created you|built you|developed you|trained you|is your (?:creator|developer|founder|maker))|"
|
| 837 |
r"what(?:'s| is) your name|your (?:name|creator|developer|founder)|who (?:is|are) (?:the )?(?:founder|creator) of opentars|"
|
| 838 |
r"(?:fundador|criador|dono|founder|creator) (?:do |da |of )?opentars)\b")
|
| 839 |
+
_PEDE_CALCULADORA = re.compile(r"\b(?:calculadora|calculator)\b")
|
| 840 |
_VERBOS_DE_BUSCA = re.compile(r"\b(?:pesquis\w*|busc\w*|procur\w*|search\w*|find|look up|look for|ache|achar)\b")
|
| 841 |
|
| 842 |
|
|
|
|
| 866 |
linhas.append("About you: your name is Gargantua. You were developed by Enzo, the founder of openTARS, and you run "
|
| 867 |
"inside the openTARS app (openTARS is the app, Gargantua is you). Say so in your own words; never "
|
| 868 |
"name another company or model as your maker.")
|
| 869 |
+
if _PEDE_CALCULADORA.search(texto_n):
|
| 870 |
+
# 10/11: o Gemma 4 trocava "quanto é 310 mais 506? usa a calculadora" por search_web (3 de 3); o Lite de 23/10 também
|
| 871 |
+
linhas.append("The user wants the Calculator app: call open_application with name=\"calculator\" and do the math "
|
| 872 |
+
"there. Do not use search_web for it.")
|
| 873 |
sites = _sites_citados_na_busca(texto_n)
|
| 874 |
if sites:
|
| 875 |
linhas.append("To search INSIDE a site, call search_web with query = only the search terms and service = the site "
|
|
|
|
| 1659 |
return _responder_sem_modelo(texto, t("imagem.pronta", arquivo=arquivo.name, pasta=arquivo.parent) + f"\n\n")
|
| 1660 |
|
| 1661 |
|
| 1662 |
+
def _resolver_sem_modelo(texto):
|
| 1663 |
+
"""5.3 (F4): o que sai por regra, sem o modelo (tars_atalhos): estado do PC, memória, contas, arquivos, vários passos e
|
| 1664 |
+
perguntas sobre o mundo pelo SearXNG. None: segue para o modelo. Um erro aqui nunca impede a conversa."""
|
| 1665 |
+
try:
|
| 1666 |
+
import tars_atalhos
|
| 1667 |
+
return tars_atalhos.resolver(texto, sys.modules[__name__])
|
| 1668 |
+
except Exception as e: # noqa: BLE001
|
| 1669 |
+
logging.warning("atalhos sem modelo falharam: %s", e)
|
| 1670 |
+
return None
|
| 1671 |
+
|
| 1672 |
+
|
| 1673 |
def processar_mensagem(texto, anexos=None):
|
| 1674 |
"""Um pedido do usuário do começo ao fim: comandos (/modelo,
|
| 1675 |
/limpar...), escolha do modelo, resposta e salvamento. Usado pelo
|
|
|
|
| 1705 |
return gerada
|
| 1706 |
import tars_imagem
|
| 1707 |
tars_imagem.parar() # o Bonsai segura a placa; a conversa (Ollama) precisa dela agora
|
| 1708 |
+
if not anexos:
|
| 1709 |
+
direta = _resolver_sem_modelo(texto)
|
| 1710 |
+
if direta is not None:
|
| 1711 |
+
return _responder_sem_modelo(texto, direta)
|
| 1712 |
|
| 1713 |
# "use a ferramenta", "faz de verdade", "try again": a tarefa e o modelo saem do pedido anterior, que é o que vai ser feito
|
| 1714 |
retoma = _ultimo_pedido_real() if not anexos and escolha.eh_retomada(normalizar(texto)) else None
|
app/nucleo/controle.py
CHANGED
|
@@ -264,7 +264,17 @@ def _abrir_em_janela_de_terminal(comando, nome):
|
|
| 264 |
"Use a non-interactive command instead (for example 'top -bn1' or 'free -h').")}
|
| 265 |
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
def _executar_terminal_de_fato(comando):
|
|
|
|
| 268 |
|
| 269 |
# 5.2: fora do Linux, os comandos de bash que o Gargantua aprendeu rodam no mini-shell em Python (tars_shell), com a
|
| 270 |
# saída no formato GNU do treino. No Windows o resto não roda; no macOS vai para o shell do sistema.
|
|
@@ -563,7 +573,7 @@ def _mirar_bruto(x=None, y=None, alvo=None, janela=None):
|
|
| 563 |
return ox + nx, oy + ny, como
|
| 564 |
if not visao_instalada():
|
| 565 |
return None, None, (f"Could not find '{alvo}': it is not in the accessibility tree and no vision model is "
|
| 566 |
-
"installed (ollama pull ProjectOpenTARS/Gargantua-2). Pass x/y from a screenshot.")
|
| 567 |
return None, None, (f"The vision model could not find '{alvo}' ({motivo_da_falha_da_visao()}). "
|
| 568 |
"Pass x/y from a screenshot.")
|
| 569 |
|
|
|
|
| 264 |
"Use a non-interactive command instead (for example 'top -bn1' or 'free -h').")}
|
| 265 |
|
| 266 |
|
| 267 |
+
def til_entre_aspas(comando):
|
| 268 |
+
"""O modelo escreve mkdir -p "~/Documentos/Jogos": o bash não expande o ~ entre aspas e cria uma pasta chamada "~" na pasta
|
| 269 |
+
atual, e a ferramenta ainda diz que deu certo (Gargantua 2.5 em Gemma 4, 10/11: 4 tarefas de pasta da prova de 80). O ~ sai
|
| 270 |
+
das aspas e o resto fica entre elas (~/"Área de Trabalho/x"): vale no bash e no mini-shell."""
|
| 271 |
+
comando = re.sub(r'"~/([^"]*)"', r'~/"\1"', comando)
|
| 272 |
+
comando = re.sub(r"'~/([^']*)'", r"~/'\1'", comando)
|
| 273 |
+
return re.sub(r'(["\'])~\1', "~", comando)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
def _executar_terminal_de_fato(comando):
|
| 277 |
+
comando = til_entre_aspas(comando)
|
| 278 |
|
| 279 |
# 5.2: fora do Linux, os comandos de bash que o Gargantua aprendeu rodam no mini-shell em Python (tars_shell), com a
|
| 280 |
# saída no formato GNU do treino. No Windows o resto não roda; no macOS vai para o shell do sistema.
|
|
|
|
| 573 |
return ox + nx, oy + ny, como
|
| 574 |
if not visao_instalada():
|
| 575 |
return None, None, (f"Could not find '{alvo}': it is not in the accessibility tree and no vision model is "
|
| 576 |
+
"installed (ollama pull ProjectOpenTARS/Gargantua-2.5). Pass x/y from a screenshot.")
|
| 577 |
return None, None, (f"The vision model could not find '{alvo}' ({motivo_da_falha_da_visao()}). "
|
| 578 |
"Pass x/y from a screenshot.")
|
| 579 |
|
app/nucleo/elementos.py
CHANGED
|
@@ -218,6 +218,16 @@ def _janela_acessivel(nome):
|
|
| 218 |
if alvo is not None:
|
| 219 |
alvo = dict(alvo, aberto_agora=True)
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
if alvo is None:
|
| 222 |
if nome and (aberta_no_x or encontrar_janela_por_nome(nome) is not None):
|
| 223 |
_marcar_sem_arvore(nome)
|
|
|
|
| 218 |
if alvo is not None:
|
| 219 |
alvo = dict(alvo, aberto_agora=True)
|
| 220 |
|
| 221 |
+
# 11/11 (F11): app Electron já aberto sem árvore: com a permissão da pessoa, reabre com a acessibilidade ligada e procura de novo
|
| 222 |
+
if alvo is None and nome and i18n.ler_config().get("reabrir_com_acessibilidade") and not _sem_arvore(nome):
|
| 223 |
+
janela_x = encontrar_janela_por_nome(nome)
|
| 224 |
+
if janela_x and janela_x.get("pid"):
|
| 225 |
+
ok, _ = reabrir_com_acessibilidade(janela_x["pid"])
|
| 226 |
+
limite = time.time() + (TIMEOUT_JANELA_ACESSIVEL_SEG if ok else 0)
|
| 227 |
+
while ok and alvo is None and time.time() < limite:
|
| 228 |
+
time.sleep(0.5)
|
| 229 |
+
alvo = procurar()
|
| 230 |
+
|
| 231 |
if alvo is None:
|
| 232 |
if nome and (aberta_no_x or encontrar_janela_por_nome(nome) is not None):
|
| 233 |
_marcar_sem_arvore(nome)
|
app/nucleo/gargantua.py
CHANGED
|
@@ -19,7 +19,7 @@
|
|
| 19 |
|
| 20 |
# ollama pull ProjectOpenTARS/Gargantua-2 (18/10: o Gargantua 2, Qwen3.5 2B com visão, é o principal; o repositório
|
| 21 |
# antigo, ProjectOpenTARS/Gargantua, continua servindo quem já o tem e os apps antigos)
|
| 22 |
-
MODELO_GARGANTUA = os.environ.get("TARS_MODELO_GARGANTUA", "ProjectOpenTARS/Gargantua-2")
|
| 23 |
REPOSITORIO_ANTIGO = "ProjectOpenTARS/Gargantua"
|
| 24 |
VERSAO_GARGANTUA = "1.2" # a do kit (gargantua/gargantua.py): 19 ferramentas
|
| 25 |
|
|
@@ -62,9 +62,16 @@ def gargantua_desligado():
|
|
| 62 |
return normalizar(os.environ.get("TARS_SEM_GARGANTUA") or "") in ("1", "sim", "yes", "on", "true")
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
def eh_gargantua(modelo):
|
| 66 |
-
"""'gargantua', 'gargantua:1.0', 'ProjectOpenTARS/Gargantua:latest'..."""
|
| 67 |
-
return
|
| 68 |
|
| 69 |
|
| 70 |
def e_gargantua_2(modelo):
|
|
@@ -73,23 +80,23 @@ def e_gargantua_2(modelo):
|
|
| 73 |
|
| 74 |
|
| 75 |
def variante_do_gargantua(modelo):
|
| 76 |
-
"""'lite' ou '
|
| 77 |
ProjectOpenTARS/Gargantua:code, Gargantua:1.2-lite); '' se é o oficial (ou qualquer outro Gargantua).
|
| 78 |
A mesma regra está no kit (gargantua.py: variante_do_nome) e em tars_primeiro_uso.py."""
|
| 79 |
base, _, tag = str(modelo or "").strip().lower().partition(":")
|
| 80 |
-
segmento =
|
| 81 |
partes = re.split(r"[-_. ]", segmento[len("gargantua"):] if segmento.startswith("gargantua") else segmento)
|
| 82 |
for parte in partes + re.split(r"[-_. ]", tag):
|
| 83 |
-
if parte in ("lite", "code"):
|
| 84 |
return parte
|
| 85 |
return ""
|
| 86 |
|
| 87 |
|
| 88 |
def ordem_do_gargantua(tag):
|
| 89 |
-
"""Quem vai na frente quando há vários Gargantua: o Gargantua 2, o oficial antigo, os outros, o Code e por fim o
|
| 90 |
variante = variante_do_gargantua(tag)
|
| 91 |
if variante:
|
| 92 |
-
return {"code": 3, "lite": 4}[variante]
|
| 93 |
if e_gargantua_2(tag):
|
| 94 |
return 0
|
| 95 |
return 1 if str(tag).lower().startswith(REPOSITORIO_ANTIGO.lower()) else 2
|
|
|
|
| 19 |
|
| 20 |
# ollama pull ProjectOpenTARS/Gargantua-2 (18/10: o Gargantua 2, Qwen3.5 2B com visão, é o principal; o repositório
|
| 21 |
# antigo, ProjectOpenTARS/Gargantua, continua servindo quem já o tem e os apps antigos)
|
| 22 |
+
MODELO_GARGANTUA = os.environ.get("TARS_MODELO_GARGANTUA", "ProjectOpenTARS/Gargantua-2.5")
|
| 23 |
REPOSITORIO_ANTIGO = "ProjectOpenTARS/Gargantua"
|
| 24 |
VERSAO_GARGANTUA = "1.2" # a do kit (gargantua/gargantua.py): 19 ferramentas
|
| 25 |
|
|
|
|
| 62 |
return normalizar(os.environ.get("TARS_SEM_GARGANTUA") or "") in ("1", "sim", "yes", "on", "true")
|
| 63 |
|
| 64 |
|
| 65 |
+
def _segmento_gargantua(base):
|
| 66 |
+
"""O pedaço do nome que diz Gargantua. Um nome local com barra ('Gargantua-lite-teste-4/11') vira namespace/modelo no
|
| 67 |
+
Ollama: olhando só o último pedaço, o app tratava o modelo como outro qualquer, ligava o raciocínio e trocava o prompt."""
|
| 68 |
+
partes = str(base or "").strip().lower().split("/")
|
| 69 |
+
return next((p for p in partes if p.startswith("gargantua")), partes[-1])
|
| 70 |
+
|
| 71 |
+
|
| 72 |
def eh_gargantua(modelo):
|
| 73 |
+
"""'gargantua', 'gargantua:1.0', 'ProjectOpenTARS/Gargantua:latest', 'Gargantua-teste-4/11'..."""
|
| 74 |
+
return _segmento_gargantua(modelo).startswith("gargantua")
|
| 75 |
|
| 76 |
|
| 77 |
def e_gargantua_2(modelo):
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
def variante_do_gargantua(modelo):
|
| 83 |
+
"""'lite', 'code', 'pro' ou 'pi' se o nome ou a etiqueta dizem que é uma VARIANTE do Gargantua (gargantua-code,
|
| 84 |
ProjectOpenTARS/Gargantua:code, Gargantua:1.2-lite); '' se é o oficial (ou qualquer outro Gargantua).
|
| 85 |
A mesma regra está no kit (gargantua.py: variante_do_nome) e em tars_primeiro_uso.py."""
|
| 86 |
base, _, tag = str(modelo or "").strip().lower().partition(":")
|
| 87 |
+
segmento = _segmento_gargantua(base)
|
| 88 |
partes = re.split(r"[-_. ]", segmento[len("gargantua"):] if segmento.startswith("gargantua") else segmento)
|
| 89 |
for parte in partes + re.split(r"[-_. ]", tag):
|
| 90 |
+
if parte in ("lite", "code", "pro", "pi"):
|
| 91 |
return parte
|
| 92 |
return ""
|
| 93 |
|
| 94 |
|
| 95 |
def ordem_do_gargantua(tag):
|
| 96 |
+
"""Quem vai na frente quando há vários Gargantua: o Gargantua 2, o oficial antigo, os outros, o Code e o Pro, o Lite e por fim o Pi."""
|
| 97 |
variante = variante_do_gargantua(tag)
|
| 98 |
if variante:
|
| 99 |
+
return {"code": 3, "pro": 3, "lite": 4, "pi": 5}[variante]
|
| 100 |
if e_gargantua_2(tag):
|
| 101 |
return 0
|
| 102 |
return 1 if str(tag).lower().startswith(REPOSITORIO_ANTIGO.lower()) else 2
|
app/nucleo/hardware.py
CHANGED
|
@@ -375,6 +375,9 @@ def _categorizar_modelo(tag, capacidades, parametros_b, familia):
|
|
| 375 |
return "embedding"
|
| 376 |
if re.search(r"(^|/)(flux|z-image)", nome):
|
| 377 |
return "embedding"
|
|
|
|
|
|
|
|
|
|
| 378 |
# Ollama antigo não informa capacidades: reconhece pelo nome os modelos
|
| 379 |
# de embeddings conhecidos (granite-embedding, nomic-embed-text...).
|
| 380 |
if not capacidades and re.search(r"embed|minilm|bge-|paraphrase-multilingual|snowflake-arctic|e5-", nome):
|
|
@@ -494,9 +497,8 @@ def modelo_ajudante(catalogo=None):
|
|
| 494 |
um nome fixo que pode nem estar instalado nesta máquina."""
|
| 495 |
|
| 496 |
catalogo = catalogo if catalogo is not None else catalogar_modelos()
|
| 497 |
-
#
|
| 498 |
-
catalogo = {t_: m for t_, m in catalogo.items()
|
| 499 |
-
if m["categoria"] != "embedding" and not visao_lib.e_cooper(t_) and not m.get("nuvem")}
|
| 500 |
|
| 501 |
candidatos = modelos_com_capacidade(catalogo, "tools") or [
|
| 502 |
m for m in catalogo.values() if m["categoria"] != "embedding"
|
|
@@ -544,8 +546,6 @@ def construir_cadeia_fallback(tarefa, catalogo=None):
|
|
| 544 |
candidatos = [m for m in catalogo.values() if m["categoria"] != "embedding" and not m.get("nuvem")]
|
| 545 |
# Os ajudantes não conversam: só entram se não houver mais nada.
|
| 546 |
candidatos = [m for m in candidatos if m["tag"] not in MODELOS_AJUDANTES] or candidatos
|
| 547 |
-
# O Cooper nunca conversa, nem como último recurso (ele só responde sobre prints).
|
| 548 |
-
candidatos = [m for m in candidatos if not visao_lib.e_cooper(m["tag"])]
|
| 549 |
if not candidatos:
|
| 550 |
return []
|
| 551 |
|
|
|
|
| 375 |
return "embedding"
|
| 376 |
if re.search(r"(^|/)(flux|z-image)", nome):
|
| 377 |
return "embedding"
|
| 378 |
+
# 5.3: o Gargantua Pi tem prompt e 4 ferramentas próprios, do openTARS Pi: com as 19 do app grande ele só atrapalharia.
|
| 379 |
+
if variante_do_gargantua(tag) == "pi":
|
| 380 |
+
return "embedding"
|
| 381 |
# Ollama antigo não informa capacidades: reconhece pelo nome os modelos
|
| 382 |
# de embeddings conhecidos (granite-embedding, nomic-embed-text...).
|
| 383 |
if not capacidades and re.search(r"embed|minilm|bge-|paraphrase-multilingual|snowflake-arctic|e5-", nome):
|
|
|
|
| 497 |
um nome fixo que pode nem estar instalado nesta máquina."""
|
| 498 |
|
| 499 |
catalogo = catalogo if catalogo is not None else catalogar_modelos()
|
| 500 |
+
# Embedding não conversa: não resolve nome de app.
|
| 501 |
+
catalogo = {t_: m for t_, m in catalogo.items() if m["categoria"] != "embedding" and not m.get("nuvem")}
|
|
|
|
| 502 |
|
| 503 |
candidatos = modelos_com_capacidade(catalogo, "tools") or [
|
| 504 |
m for m in catalogo.values() if m["categoria"] != "embedding"
|
|
|
|
| 546 |
candidatos = [m for m in catalogo.values() if m["categoria"] != "embedding" and not m.get("nuvem")]
|
| 547 |
# Os ajudantes não conversam: só entram se não houver mais nada.
|
| 548 |
candidatos = [m for m in candidatos if m["tag"] not in MODELOS_AJUDANTES] or candidatos
|
|
|
|
|
|
|
| 549 |
if not candidatos:
|
| 550 |
return []
|
| 551 |
|
app/nucleo/nuvem.py
CHANGED
|
@@ -28,7 +28,7 @@ def servidor_api():
|
|
| 28 |
dados = nuvem_config()
|
| 29 |
if not (dados["ativa"] and dados["endereco"] and dados["chave"]):
|
| 30 |
return ""
|
| 31 |
-
return dados["endereco"]
|
| 32 |
|
| 33 |
|
| 34 |
def _cabecalhos_api():
|
|
|
|
| 28 |
dados = nuvem_config()
|
| 29 |
if not (dados["ativa"] and dados["endereco"] and dados["chave"]):
|
| 30 |
return ""
|
| 31 |
+
return api.normalizar_url(dados["endereco"]) # "localhost:1234" escrito à mão não chegava a lugar nenhum
|
| 32 |
|
| 33 |
|
| 34 |
def _cabecalhos_api():
|
app/nucleo/ollama.py
CHANGED
|
@@ -80,9 +80,8 @@ def _chat_unico(
|
|
| 80 |
"keep_alive": keep_alive,
|
| 81 |
"options": {"temperature": temperatura},
|
| 82 |
}
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
corpo["options"]["num_ctx"] = contexto_ia()
|
| 86 |
if opcoes:
|
| 87 |
corpo["options"].update(opcoes)
|
| 88 |
|
|
@@ -189,6 +188,26 @@ def aquecer_modelo_da_conversa():
|
|
| 189 |
threading.Thread(target=_pedir, daemon=True).start()
|
| 190 |
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
def descarregar_modelo(modelo):
|
| 193 |
"""Pede ao Ollama para descarregar um modelo (keep_alive=0).
|
| 194 |
Retorna True se a chamada foi aceita — isso NÃO garante que o
|
|
@@ -218,7 +237,7 @@ def descarregar_modelo(modelo):
|
|
| 218 |
def ollama_online():
|
| 219 |
"""O servidor do Ollama responde no endereço configurado?"""
|
| 220 |
try:
|
| 221 |
-
return _SESSION.get(
|
| 222 |
except Exception:
|
| 223 |
return False
|
| 224 |
|
|
|
|
| 80 |
"keep_alive": keep_alive,
|
| 81 |
"options": {"temperature": temperatura},
|
| 82 |
}
|
| 83 |
+
# mesmo num_ctx do chat: senão o Ollama recarrega o modelo a cada consulta de bastidor
|
| 84 |
+
corpo["options"]["num_ctx"] = contexto_ia()
|
|
|
|
| 85 |
if opcoes:
|
| 86 |
corpo["options"].update(opcoes)
|
| 87 |
|
|
|
|
| 188 |
threading.Thread(target=_pedir, daemon=True).start()
|
| 189 |
|
| 190 |
|
| 191 |
+
_KEEP_ALIVE_EM_SEGUNDO_PLANO = KEEP_ALIVE
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def manter_modelo_com_janela(aberta):
|
| 195 |
+
"""Janela aberta: o modelo fica na memória e responder não recarrega. Janela escondida (bandeja) ou fechada: volta ao tempo
|
| 196 |
+
normal, para não gastar memória em segundo plano. TARS_KEEP_ALIVE escolhido à mão continua valendo."""
|
| 197 |
+
global KEEP_ALIVE
|
| 198 |
+
if os.environ.get("TARS_KEEP_ALIVE"):
|
| 199 |
+
return
|
| 200 |
+
# O número -1, não o texto "-1": o Ollama 0.40.13 recusa "-1" (HTTP 400, 'time: missing unit in duration') e todo pedido falhava.
|
| 201 |
+
KEEP_ALIVE = -1 if aberta else _KEEP_ALIVE_EM_SEGUNDO_PLANO
|
| 202 |
+
# o Ollama guarda o tempo do último pedido: sem este aviso, o modelo carregado com -1 nunca sairia da memória
|
| 203 |
+
for modelo in (listar_modelos_rodando() or set()) & (modelos_do_tars | ({modelo_atual} if modelo_atual else set())):
|
| 204 |
+
try:
|
| 205 |
+
_SESSION.post(OLLAMA_GENERATE_URL, json={"model": modelo, "prompt": "", "keep_alive": KEEP_ALIVE,
|
| 206 |
+
"options": {"num_ctx": contexto_ia()}}, timeout=TIMEOUT_HTTP_DESCARREGAR)
|
| 207 |
+
except Exception:
|
| 208 |
+
pass
|
| 209 |
+
|
| 210 |
+
|
| 211 |
def descarregar_modelo(modelo):
|
| 212 |
"""Pede ao Ollama para descarregar um modelo (keep_alive=0).
|
| 213 |
Retorna True se a chamada foi aceita — isso NÃO garante que o
|
|
|
|
| 237 |
def ollama_online():
|
| 238 |
"""O servidor do Ollama responde no endereço configurado?"""
|
| 239 |
try:
|
| 240 |
+
return _SESSION.get(motor_lib.url("versao", OLLAMA_HOST), timeout=TIMEOUT_HTTP_CURTO).status_code == 200
|
| 241 |
except Exception:
|
| 242 |
return False
|
| 243 |
|
app/nucleo/registro.py
CHANGED
|
@@ -434,7 +434,7 @@ class _Girando:
|
|
| 434 |
quadro = self.QUADROS[i % len(self.QUADROS)]
|
| 435 |
segundos = int(time.time() - self.desde)
|
| 436 |
linha = (f"\r\033[K{Cor.MAGENTA}{quadro}{Cor.RESET} {self.texto}… "
|
| 437 |
-
f"{Cor.CINZA}({segundos}
|
| 438 |
self.saida.write(linha)
|
| 439 |
self.saida.flush()
|
| 440 |
self.visivel = True
|
|
@@ -519,7 +519,9 @@ def _term_resposta(d):
|
|
| 519 |
if d.get("apos_pensamento"):
|
| 520 |
print()
|
| 521 |
print("\n" + c("● ", Cor.VERDE), end="")
|
| 522 |
-
|
|
|
|
|
|
|
| 523 |
|
| 524 |
|
| 525 |
def _term_tempo(d):
|
|
@@ -554,7 +556,7 @@ _TERMINAL = {
|
|
| 554 |
"descarregando": _term_descarregando,
|
| 555 |
"pensamento": _term_pensamento,
|
| 556 |
"resposta": _term_resposta,
|
| 557 |
-
"fim_stream": lambda d: print(flush=True),
|
| 558 |
"tempo": _term_tempo,
|
| 559 |
"folego": _term_folego,
|
| 560 |
"analisando": _term_analisando,
|
|
|
|
| 434 |
quadro = self.QUADROS[i % len(self.QUADROS)]
|
| 435 |
segundos = int(time.time() - self.desde)
|
| 436 |
linha = (f"\r\033[K{Cor.MAGENTA}{quadro}{Cor.RESET} {self.texto}… "
|
| 437 |
+
f"{Cor.CINZA}({_tempo_curto(segundos)} · {t('term.parar')}){Cor.RESET}")
|
| 438 |
self.saida.write(linha)
|
| 439 |
self.saida.flush()
|
| 440 |
self.visivel = True
|
|
|
|
| 519 |
if d.get("apos_pensamento"):
|
| 520 |
print()
|
| 521 |
print("\n" + c("● ", Cor.VERDE), end="")
|
| 522 |
+
_MD.reiniciar()
|
| 523 |
+
texto = _MD.alimentar(d["texto"]) if _girando.ligado else d["texto"] # 5.3: formatado só num terminal de verdade
|
| 524 |
+
print(_recuar(texto), end="", flush=True)
|
| 525 |
|
| 526 |
|
| 527 |
def _term_tempo(d):
|
|
|
|
| 556 |
"descarregando": _term_descarregando,
|
| 557 |
"pensamento": _term_pensamento,
|
| 558 |
"resposta": _term_resposta,
|
| 559 |
+
"fim_stream": lambda d: print(_recuar(_MD.terminar()) if _girando.ligado else "", flush=True),
|
| 560 |
"tempo": _term_tempo,
|
| 561 |
"folego": _term_folego,
|
| 562 |
"analisando": _term_analisando,
|
app/nucleo/selecao.py
CHANGED
|
@@ -582,9 +582,9 @@ _CRITERIOS_MODELO = {
|
|
| 582 |
|
| 583 |
|
| 584 |
def _so_de_conversa(catalogo):
|
| 585 |
-
""""/modelo o menor", "o de visão"...: só entra modelo que conversa (
|
| 586 |
return {t_: m for t_, m in catalogo.items()
|
| 587 |
-
if m["categoria"] != "embedding" and not
|
| 588 |
|
| 589 |
|
| 590 |
def _extremo_por_tamanho(catalogo, menor):
|
|
|
|
| 582 |
|
| 583 |
|
| 584 |
def _so_de_conversa(catalogo):
|
| 585 |
+
""""/modelo o menor", "o de visão"...: só entra modelo que conversa (não embedding)."""
|
| 586 |
return {t_: m for t_, m in catalogo.items()
|
| 587 |
+
if m["categoria"] != "embedding" and not m.get("nuvem")}
|
| 588 |
|
| 589 |
|
| 590 |
def _extremo_por_tamanho(catalogo, menor):
|
app/nucleo/terminal.py
CHANGED
|
@@ -216,14 +216,7 @@ def _tratar_comando_do_chat(linha):
|
|
| 216 |
|
| 217 |
|
| 218 |
def _linha_de_status():
|
| 219 |
-
|
| 220 |
-
usados = estimar_tokens(conversa)
|
| 221 |
-
partes = [str(modelo), t("cli.status_contexto", pct=min(100, int(100 * usados / max(1, contexto_ia()))))]
|
| 222 |
-
if MODO_PERMISSAO != "perguntar":
|
| 223 |
-
partes.append(t(f"cli.status_modo.{MODO_PERMISSAO}"))
|
| 224 |
-
if CONTEXTO_PROJETO["caminho"]:
|
| 225 |
-
partes.append("AGENTS.md")
|
| 226 |
-
print(c(" " + " · ".join(partes), Cor.CINZA))
|
| 227 |
|
| 228 |
|
| 229 |
def _mostrar_plano():
|
|
@@ -285,12 +278,16 @@ def _boas_vindas(pedidos):
|
|
| 285 |
else:
|
| 286 |
garg = gargantua_instalado() if online else None
|
| 287 |
modelo = t("term.modelo_auto") + (f" · {garg.split('/')[-1]}" if garg else "")
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
| 291 |
c(" " + t("chat.dica"), Cor.CINZA), "",
|
| 292 |
-
c(" " + t("term.
|
| 293 |
-
c(" " + t("term.rotulo_modelo") + " ", Cor.CINZA) + modelo
|
|
|
|
| 294 |
if pedidos:
|
| 295 |
linhas.append(c(" " + t("term.conversa_carregada", n=pedidos), Cor.CINZA))
|
| 296 |
print()
|
|
@@ -376,14 +373,20 @@ def _conversar(pedido_unico, retomar=None):
|
|
| 376 |
if retomar == "escolher":
|
| 377 |
_escolher_conversa()
|
| 378 |
interativo = sys.stdin.isatty()
|
|
|
|
|
|
|
| 379 |
ctrl_c = 0
|
| 380 |
try:
|
| 381 |
while True:
|
| 382 |
try:
|
| 383 |
# \001..\002 avisam o readline que as cores não ocupam coluna (senão a edição da linha embaralha)
|
| 384 |
ini, fim = ("\001", "\002") if TEM_READLINE else ("", "")
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
except EOFError:
|
| 388 |
print()
|
| 389 |
break
|
|
@@ -404,7 +407,7 @@ def _conversar(pedido_unico, retomar=None):
|
|
| 404 |
_executar_pedido_no_terminal(linha)
|
| 405 |
_mostrar_plano()
|
| 406 |
_guardar_no_historico()
|
| 407 |
-
if interativo:
|
| 408 |
_linha_de_status()
|
| 409 |
finally:
|
| 410 |
if ollama_online():
|
|
@@ -508,13 +511,6 @@ def modo_print(pedido, formato="text", modelo=None):
|
|
| 508 |
return 0 if ok else 1
|
| 509 |
|
| 510 |
|
| 511 |
-
def comando_cooper(instalar=True):
|
| 512 |
-
"""opentars --cooper: o Cooper foi descontinuado (18/10/2026); o Gargantua 2 enxerga a tela. Só avisa e diz como instalar."""
|
| 513 |
-
print(c(t("cooper.descontinuado", comando=visao_lib.COMANDO_INSTALAR), Cor.AMARELO))
|
| 514 |
-
return 0
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
# ------------------------------------------------------------
|
| 519 |
# Logo no terminal: o monólito do TARS (quatro colunas e a faixa verde, como o ícone) com o nome ao lado.
|
| 520 |
# Só aparece num terminal de verdade (não em pipe nem em script) e respeita NO_COLOR.
|
|
@@ -598,6 +594,10 @@ def main():
|
|
| 598 |
import tars_prova
|
| 599 |
sys.exit(tars_prova.executar(sys.modules[__name__], argumentos))
|
| 600 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
if any(a in ("--desinstalar", "--uninstall") for a in argumentos):
|
| 602 |
import tars_desinstalar
|
| 603 |
sys.exit(tars_desinstalar.executar(OLLAMA_HOST, t))
|
|
@@ -627,10 +627,6 @@ def main():
|
|
| 627 |
if "--searxng" in argumentos:
|
| 628 |
sys.exit(comando_searxng())
|
| 629 |
|
| 630 |
-
# --cooper: o Cooper foi descontinuado, o comando só avisa
|
| 631 |
-
if any(a in ("--cooper",) for a in argumentos):
|
| 632 |
-
sys.exit(comando_cooper())
|
| 633 |
-
|
| 634 |
# 5.1: modo de permissão (perguntar, plano, automático) e qual conversa retomar
|
| 635 |
global MODO_PERMISSAO
|
| 636 |
if any(a in ("--plan", "--plano") for a in argumentos):
|
|
@@ -665,6 +661,8 @@ def main():
|
|
| 665 |
palavras.append(arg)
|
| 666 |
i += 1
|
| 667 |
pedido = " ".join(palavras).strip()
|
|
|
|
|
|
|
| 668 |
sys.exit(modo_chat_terminal(pedido_unico=pedido or None, retomar=retomar))
|
| 669 |
|
| 670 |
# --voz: fica ouvindo "TARS" e responde falando (nucleo/voz.py)
|
|
|
|
| 216 |
|
| 217 |
|
| 218 |
def _linha_de_status():
|
| 219 |
+
print(c(" " + " · ".join(_partes_do_status()), Cor.CINZA))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
|
| 222 |
def _mostrar_plano():
|
|
|
|
| 278 |
else:
|
| 279 |
garg = gargantua_instalado() if online else None
|
| 280 |
modelo = t("term.modelo_auto") + (f" · {garg.split('/')[-1]}" if garg else "")
|
| 281 |
+
nome_motor = _nome_do_motor()
|
| 282 |
+
motor_linha = (c("●", Cor.VERDE) + " " + nome_motor + " · " + t("term.ollama_online", host=OLLAMA_HOST) if online
|
| 283 |
+
else c("●", Cor.VERMELHO) + " " + nome_motor + " · "
|
| 284 |
+
+ t("term.ollama_offline" if nome_motor == "Ollama" else "term.motor_offline", host=OLLAMA_HOST))
|
| 285 |
+
ramo = _ramo_git()
|
| 286 |
+
linhas = [c("✻ ", Cor.MAGENTA) + c(t("term.bem_vindo"), Cor.NEGRITO) + c(f" {VERSAO_EXIBIDA}", Cor.CINZA), "",
|
| 287 |
c(" " + t("chat.dica"), Cor.CINZA), "",
|
| 288 |
+
c(" " + t("term.rotulo_motor") + " ", Cor.CINZA) + motor_linha,
|
| 289 |
+
c(" " + t("term.rotulo_modelo") + " ", Cor.CINZA) + modelo,
|
| 290 |
+
c(" " + t("term.rotulo_pasta") + " ", Cor.CINZA) + _pasta_curta() + (c(f" ⎇ {ramo}", Cor.CINZA) if ramo else "")]
|
| 291 |
if pedidos:
|
| 292 |
linhas.append(c(" " + t("term.conversa_carregada", n=pedidos), Cor.CINZA))
|
| 293 |
print()
|
|
|
|
| 373 |
if retomar == "escolher":
|
| 374 |
_escolher_conversa()
|
| 375 |
interativo = sys.stdin.isatty()
|
| 376 |
+
# 5.3 (F8, só aparência): caixa de entrada do Claude Code com a barra do opencode; sem readline ou sem cor, a régua de antes
|
| 377 |
+
caixa = interativo and sys.stdout.isatty() and TEM_READLINE and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb"
|
| 378 |
ctrl_c = 0
|
| 379 |
try:
|
| 380 |
while True:
|
| 381 |
try:
|
| 382 |
# \001..\002 avisam o readline que as cores não ocupam coluna (senão a edição da linha embaralha)
|
| 383 |
ini, fim = ("\001", "\002") if TEM_READLINE else ("", "")
|
| 384 |
+
if caixa:
|
| 385 |
+
linha = _pedir_na_caixa(ini, fim).strip()
|
| 386 |
+
else:
|
| 387 |
+
if interativo:
|
| 388 |
+
print("\n" + _regua(MODO_PERMISSAO)) # 5.3: régua com o modo da conversa, como a barra do opencode
|
| 389 |
+
linha = input(ini + Cor.NEGRITO + Cor.VERDE + fim + "❯ " + ini + Cor.RESET + fim if interativo else "").strip()
|
| 390 |
except EOFError:
|
| 391 |
print()
|
| 392 |
break
|
|
|
|
| 407 |
_executar_pedido_no_terminal(linha)
|
| 408 |
_mostrar_plano()
|
| 409 |
_guardar_no_historico()
|
| 410 |
+
if interativo and not caixa: # com a caixa, a barra de estado já vem embaixo dela
|
| 411 |
_linha_de_status()
|
| 412 |
finally:
|
| 413 |
if ollama_online():
|
|
|
|
| 511 |
return 0 if ok else 1
|
| 512 |
|
| 513 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
# ------------------------------------------------------------
|
| 515 |
# Logo no terminal: o monólito do TARS (quatro colunas e a faixa verde, como o ícone) com o nome ao lado.
|
| 516 |
# Só aparece num terminal de verdade (não em pipe nem em script) e respeita NO_COLOR.
|
|
|
|
| 594 |
import tars_prova
|
| 595 |
sys.exit(tars_prova.executar(sys.modules[__name__], argumentos))
|
| 596 |
|
| 597 |
+
if "--missao-vm" in argumentos: # 5.3 (F7): missão do Gargantua Pro numa VM descartável
|
| 598 |
+
import tars_vm_cli
|
| 599 |
+
sys.exit(tars_vm_cli.executar(OLLAMA_HOST, argumentos, t, instalados=lambda: primeiro_uso.modelos_instalados(OLLAMA_HOST)))
|
| 600 |
+
|
| 601 |
if any(a in ("--desinstalar", "--uninstall") for a in argumentos):
|
| 602 |
import tars_desinstalar
|
| 603 |
sys.exit(tars_desinstalar.executar(OLLAMA_HOST, t))
|
|
|
|
| 627 |
if "--searxng" in argumentos:
|
| 628 |
sys.exit(comando_searxng())
|
| 629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
# 5.1: modo de permissão (perguntar, plano, automático) e qual conversa retomar
|
| 631 |
global MODO_PERMISSAO
|
| 632 |
if any(a in ("--plan", "--plano") for a in argumentos):
|
|
|
|
| 661 |
palavras.append(arg)
|
| 662 |
i += 1
|
| 663 |
pedido = " ".join(palavras).strip()
|
| 664 |
+
if not pedido and sys.stdin.isatty():
|
| 665 |
+
_mostrar_logo() # 5.3: a conversa abre com o logo, como sem argumento
|
| 666 |
sys.exit(modo_chat_terminal(pedido_unico=pedido or None, retomar=retomar))
|
| 667 |
|
| 668 |
# --voz: fica ouvindo "TARS" e responde falando (nucleo/voz.py)
|
app/nucleo/visao.py
CHANGED
|
@@ -76,7 +76,7 @@ def _visao_cabe_junto(tag):
|
|
| 76 |
catalogo = catalogar_modelos()
|
| 77 |
meu = (catalogo.get(tag) or {}).get("vram_estimado_gb")
|
| 78 |
outros = [m.get("vram_estimado_gb") for m in catalogo.values()
|
| 79 |
-
if
|
| 80 |
and m.get("vram_estimado_gb") is not None]
|
| 81 |
if not gpu or meu is None or not outros:
|
| 82 |
return False
|
|
|
|
| 76 |
catalogo = catalogar_modelos()
|
| 77 |
meu = (catalogo.get(tag) or {}).get("vram_estimado_gb")
|
| 78 |
outros = [m.get("vram_estimado_gb") for m in catalogo.values()
|
| 79 |
+
if m.get("categoria") != "embedding"
|
| 80 |
and m.get("vram_estimado_gb") is not None]
|
| 81 |
if not gpu or meu is None or not outros:
|
| 82 |
return False
|
app/nucleo/visual.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# openTARS — nucleo/visual.py
|
| 2 |
+
# Aparência do terminal novo da 5.3 (F8): um híbrido do CLI do Claude Code (● passo, ⎿ resultado, resposta formatada, aviso
|
| 3 |
+
# de contexto) com o do opencode (régua com o modo da conversa e barra de estado com pasta e ramo). Só desenho: nada aqui
|
| 4 |
+
# decide o que a IA faz. Parte do núcleo: roda dentro do namespace do tars.py, como as outras (ver _carregar_nucleo).
|
| 5 |
+
# flake8: noqa
|
| 6 |
+
|
| 7 |
+
import re as _re
|
| 8 |
+
import shutil as _shutil
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _largura_do_terminal(maximo=100):
|
| 12 |
+
return max(40, min(maximo, _shutil.get_terminal_size((80, 20)).columns))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _tempo_curto(segundos):
|
| 16 |
+
"""12 -> "12s"; 65 -> "1m 05s"."""
|
| 17 |
+
segundos = int(segundos)
|
| 18 |
+
return f"{segundos}s" if segundos < 60 else f"{segundos // 60}m {segundos % 60:02d}s"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _nome_do_motor():
|
| 22 |
+
""""llama.cpp" se o app fala com o motor próprio (porta 11435); senão "Ollama"."""
|
| 23 |
+
try:
|
| 24 |
+
import tars_motor_local
|
| 25 |
+
if str(OLLAMA_HOST).rstrip("/") == tars_motor_local.URL:
|
| 26 |
+
return "llama.cpp"
|
| 27 |
+
except Exception: # noqa: BLE001
|
| 28 |
+
pass
|
| 29 |
+
return "Ollama"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _pasta_curta():
|
| 33 |
+
"""A pasta atual, com ~ no lugar da pasta pessoal."""
|
| 34 |
+
try:
|
| 35 |
+
pasta = os.path.realpath(os.getcwd()) # realpath dos dois lados: no macOS /var é /private/var e o ~ não casava
|
| 36 |
+
except OSError: # a pasta atual foi apagada: o enfeite não derruba o chat
|
| 37 |
+
return ""
|
| 38 |
+
casa = os.path.realpath(os.path.expanduser("~"))
|
| 39 |
+
if pasta == casa or pasta.startswith(casa + os.sep):
|
| 40 |
+
pasta = "~" + pasta[len(casa):]
|
| 41 |
+
return pasta if len(pasta) <= 40 else "…" + pasta[-39:]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _ramo_git():
|
| 45 |
+
"""O ramo do git da pasta atual, ou "" (sem git, fora de repositório, ou demorou)."""
|
| 46 |
+
try:
|
| 47 |
+
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, timeout=1.0,
|
| 48 |
+
cwd=os.getcwd(), env=_env_filho() if "_env_filho" in globals() else None)
|
| 49 |
+
return r.stdout.strip() if r.returncode == 0 else ""
|
| 50 |
+
except Exception: # noqa: BLE001 (sem git, ou um teste que troca o subprocess: o enfeite nunca derruba o chat)
|
| 51 |
+
return ""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _regua(modo):
|
| 55 |
+
"""A linha que separa um pedido do outro, com o modo da conversa à direita (como a barra do opencode)."""
|
| 56 |
+
rotulo = " " + t(f"cli.regua.{modo}") + " "
|
| 57 |
+
largura = _largura_do_terminal()
|
| 58 |
+
return c("─" * max(4, largura - len(rotulo) - 2), Cor.CINZA) + c(rotulo, Cor.CINZA) + c("──", Cor.CINZA)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _partes_do_status():
|
| 62 |
+
"""Modelo, contexto usado, pasta com o ramo, modo (quando não é o padrão) e AGENTS.md: a mesma conta da linha de status."""
|
| 63 |
+
modelo = modelo_atual or (modelo_manual if modo_modelo == "MANUAL" else "auto")
|
| 64 |
+
usados = estimar_tokens(conversa)
|
| 65 |
+
partes = [str(modelo).split("/")[-1], t("cli.status_contexto", pct=min(100, int(100 * usados / max(1, contexto_ia()))))]
|
| 66 |
+
ramo = _ramo_git()
|
| 67 |
+
partes.append(_pasta_curta() + (f" ⎇ {ramo}" if ramo else ""))
|
| 68 |
+
if MODO_PERMISSAO != "perguntar":
|
| 69 |
+
partes.append(t(f"cli.status_modo.{MODO_PERMISSAO}"))
|
| 70 |
+
if CONTEXTO_PROJETO["caminho"]:
|
| 71 |
+
partes.append("AGENTS.md")
|
| 72 |
+
return partes
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
_SELO = "\033[1;38;5;16;48;5;78m" # preto em negrito sobre verde (256 cores): o selo do openTARS na barra (como o do modo no opencode)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _barra_de_estado(largura):
|
| 79 |
+
"""A barra do opencode embaixo da caixa: selo "openTARS", modelo · motor · contexto · pasta ⎇ ramo, e a dica à direita."""
|
| 80 |
+
partes = _partes_do_status()
|
| 81 |
+
partes.insert(1, _nome_do_motor())
|
| 82 |
+
esquerda = " openTARS "
|
| 83 |
+
meio = " " + " · ".join(partes)
|
| 84 |
+
dica = t("cli.barra_dica")
|
| 85 |
+
sobra = largura - len(esquerda) - len(meio) - len(dica) - 1
|
| 86 |
+
if sobra < 1:
|
| 87 |
+
meio, sobra = meio[:max(0, largura - len(esquerda) - 2)], 0
|
| 88 |
+
dica = ""
|
| 89 |
+
return _SELO + esquerda + Cor.RESET + c(meio, Cor.CINZA) + " " * sobra + c(dica, Cor.CINZA)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _pedir_na_caixa(ini, fim):
|
| 93 |
+
"""Claude Code: o pedido é digitado dentro de uma caixa de cantos redondos, com a barra de estado logo abaixo. A caixa e a
|
| 94 |
+
barra são desenhadas antes e o cursor sobe para dentro; depois do Enter a linha ganha a borda da direita e o cursor desce
|
| 95 |
+
além da barra. Só num terminal de verdade com readline (as marcas \\001 e \\002 mantêm a edição da linha certa)."""
|
| 96 |
+
largura = _largura_do_terminal() - 1
|
| 97 |
+
borda = lambda s: c(s, Cor.CINZA)
|
| 98 |
+
sys.stdout.write("\n" + borda("╭" + "─" * (largura - 2) + "╮") + "\n" + borda("│") + " " * (largura - 2) + borda("│") + "\n"
|
| 99 |
+
+ borda("╰" + "─" * (largura - 2) + "╯") + "\n" + _barra_de_estado(largura) + "\033[2A\r")
|
| 100 |
+
sys.stdout.flush()
|
| 101 |
+
try:
|
| 102 |
+
linha = input(ini + Cor.CINZA + fim + "│ " + ini + Cor.NEGRITO + Cor.VERDE + fim + "❯ " + ini + Cor.RESET + fim)
|
| 103 |
+
except BaseException:
|
| 104 |
+
sys.stdout.write("\r\n\033[J") # Ctrl+C ou Ctrl+D: some a caixa de baixo e a barra, a mensagem sai limpa
|
| 105 |
+
sys.stdout.flush()
|
| 106 |
+
raise
|
| 107 |
+
visivel = "│ ❯ " + linha
|
| 108 |
+
if len(visivel) <= largura - 2: # coube numa linha: fecha a borda da direita e pula a borda de baixo e a barra
|
| 109 |
+
sys.stdout.write("\033[1A\r" + borda("│") + " " + c("❯ ", Cor.NEGRITO + Cor.VERDE) + linha
|
| 110 |
+
+ " " * (largura - 1 - len(visivel)) + borda("│") + "\n\033[1B\n")
|
| 111 |
+
else: # quebrou em várias linhas: a caixa já se desfez; só limpa o que sobrou embaixo
|
| 112 |
+
sys.stdout.write("\033[J")
|
| 113 |
+
sys.stdout.flush()
|
| 114 |
+
return linha
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class _MarkdownTerminal:
|
| 118 |
+
"""Formata a resposta, que chega aos pedaços, linha por linha: títulos em negrito, listas com •, **negrito**, `código` em
|
| 119 |
+
ciano e blocos de código com uma barra. Só vale num terminal de verdade (a janela e os pipes recebem o texto puro)."""
|
| 120 |
+
|
| 121 |
+
def __init__(self):
|
| 122 |
+
self.buf = ""
|
| 123 |
+
self.em_codigo = False
|
| 124 |
+
|
| 125 |
+
def reiniciar(self):
|
| 126 |
+
self.buf, self.em_codigo = "", False
|
| 127 |
+
|
| 128 |
+
def alimentar(self, texto):
|
| 129 |
+
"""Devolve o que já pode ser impresso (as linhas completas); o resto espera a próxima quebra de linha."""
|
| 130 |
+
self.buf += texto
|
| 131 |
+
if "\n" not in self.buf:
|
| 132 |
+
return ""
|
| 133 |
+
*linhas, self.buf = self.buf.split("\n")
|
| 134 |
+
return "".join(self._linha(l) + "\n" for l in linhas)
|
| 135 |
+
|
| 136 |
+
def terminar(self):
|
| 137 |
+
resto, self.buf = self.buf, ""
|
| 138 |
+
saida = self._linha(resto) if resto else ""
|
| 139 |
+
self.em_codigo = False
|
| 140 |
+
return saida
|
| 141 |
+
|
| 142 |
+
@staticmethod
|
| 143 |
+
def _inline(linha):
|
| 144 |
+
linha = _re.sub(r"\*\*(.+?)\*\*", lambda m: Cor.NEGRITO + m.group(1) + Cor.RESET, linha)
|
| 145 |
+
return _re.sub(r"`([^`\n]+)`", lambda m: Cor.CIANO + m.group(1) + Cor.RESET, linha)
|
| 146 |
+
|
| 147 |
+
def _linha(self, linha):
|
| 148 |
+
if linha.lstrip().startswith("```"):
|
| 149 |
+
self.em_codigo = not self.em_codigo
|
| 150 |
+
rotulo = linha.strip().strip("`").strip()
|
| 151 |
+
return c("╭─ " + rotulo if self.em_codigo and rotulo else "╭─" if self.em_codigo else "╰─", Cor.CINZA)
|
| 152 |
+
if self.em_codigo:
|
| 153 |
+
return c("│ ", Cor.CINZA) + linha
|
| 154 |
+
m = _re.match(r"^(#{1,6})\s+(.*)$", linha)
|
| 155 |
+
if m:
|
| 156 |
+
return Cor.NEGRITO + (Cor.CIANO if len(m.group(1)) == 1 else "") + self._inline(m.group(2)) + Cor.RESET
|
| 157 |
+
m = _re.match(r"^(\s*)[-*]\s+(.*)$", linha)
|
| 158 |
+
if m:
|
| 159 |
+
return m.group(1) + c("•", Cor.CINZA) + " " + self._inline(m.group(2))
|
| 160 |
+
if linha.startswith("> "):
|
| 161 |
+
return c("│ ", Cor.CINZA) + c(self._inline(linha[2:]), Cor.ITALICO)
|
| 162 |
+
return self._inline(linha)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
_MD = _MarkdownTerminal()
|
app/nucleo/voz.py
CHANGED
|
@@ -61,12 +61,12 @@ def preferencias_voz():
|
|
| 61 |
velocidade = float(dados.get("voz_velocidade") or 1.0)
|
| 62 |
except (TypeError, ValueError):
|
| 63 |
velocidade = 1.0
|
| 64 |
-
return {"vozes": vozes, "velocidade": velocidade}
|
| 65 |
|
| 66 |
|
| 67 |
def aplicar_preferencias_voz():
|
| 68 |
p = preferencias_voz()
|
| 69 |
-
voz_lib.configurar(vozes=p["vozes"], velocidade=p["velocidade"], idioma=i18n.idioma_atual())
|
| 70 |
|
| 71 |
|
| 72 |
def voz_atual(idioma=None):
|
|
|
|
| 61 |
velocidade = float(dados.get("voz_velocidade") or 1.0)
|
| 62 |
except (TypeError, ValueError):
|
| 63 |
velocidade = 1.0
|
| 64 |
+
return {"vozes": vozes, "velocidade": velocidade, "curta": not dados.get("voz_resposta_completa")}
|
| 65 |
|
| 66 |
|
| 67 |
def aplicar_preferencias_voz():
|
| 68 |
p = preferencias_voz()
|
| 69 |
+
voz_lib.configurar(vozes=p["vozes"], velocidade=p["velocidade"], idioma=i18n.idioma_atual(), curta=p["curta"])
|
| 70 |
|
| 71 |
|
| 72 |
def voz_atual(idioma=None):
|
app/tars.py
CHANGED
|
@@ -29,6 +29,7 @@ import requests
|
|
| 29 |
import psutil
|
| 30 |
from PIL import Image
|
| 31 |
|
|
|
|
| 32 |
import tars_i18n as i18n
|
| 33 |
from tars_i18n import t
|
| 34 |
import tars_acessibilidade as acess
|
|
@@ -160,7 +161,7 @@ if SESSAO_GRAFICA == "wayland" and os.environ.get("TARS_YDOTOOL", "1") not in ("
|
|
| 160 |
# CONFIGURAÇÃO
|
| 161 |
# ============================================================
|
| 162 |
|
| 163 |
-
VERSAO = "5.
|
| 164 |
# 3.0 Miller: o classificador Murph e o modo AUTO.
|
| 165 |
# 4.0 Endurance: o Gargantua (IA oficial); Endurance Neo: aviso de versão nova, tarefas longas.
|
| 166 |
# 4.5 Endurance: o Cooper (visão da tela), WSL e Linux sem interface gráfica.
|
|
@@ -171,11 +172,11 @@ VERSAO = "5.2"
|
|
| 171 |
# conta do Ollama e pesquisa web pelo SearXNG.
|
| 172 |
# 5.2 Ranger: Windows, macOS e outros Linux; o assistente do sistema (lançador, ações de texto, arquivos e fotos);
|
| 173 |
# Gargantua 2 retreinado com os dados da 5.2.
|
| 174 |
-
CODINOME = "
|
| 175 |
VERSAO_EXIBIDA = f"{VERSAO} {CODINOME}"
|
| 176 |
# Endurance Neo: sobe a cada pacote publicado (AAAAMMDDNN). O aviso de versão
|
| 177 |
# nova compara com a REVISAO do tars.py do GitHub (tars_atualizacao.py).
|
| 178 |
-
REVISAO =
|
| 179 |
|
| 180 |
# Endereço do servidor Ollama — respeita a variável de ambiente padrão
|
| 181 |
# do próprio Ollama (OLLAMA_HOST), pra funcionar sem editar o código
|
|
@@ -224,11 +225,18 @@ def _normalizar_ollama_host(valor):
|
|
| 224 |
|
| 225 |
|
| 226 |
OLLAMA_HOST = _normalizar_ollama_host(os.environ.get("OLLAMA_HOST"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
|
| 228 |
|
| 229 |
def _ollama_responde(url, espera=0.8):
|
| 230 |
try:
|
| 231 |
-
return requests.get(
|
| 232 |
except Exception:
|
| 233 |
return False
|
| 234 |
|
|
@@ -244,11 +252,11 @@ if EM_WSL and not os.environ.get("OLLAMA_HOST") and not _ollama_responde(OLLAMA_
|
|
| 244 |
OLLAMA_HOST, OLLAMA_NO_WINDOWS = _candidato, True
|
| 245 |
break
|
| 246 |
|
| 247 |
-
OLLAMA_URL =
|
| 248 |
-
OLLAMA_TAGS_URL =
|
| 249 |
-
OLLAMA_SHOW_URL =
|
| 250 |
-
OLLAMA_GENERATE_URL =
|
| 251 |
-
OLLAMA_PS_URL =
|
| 252 |
|
| 253 |
# Quanto tempo cada modelo fica na memória (RAM/VRAM) depois do último uso: o
|
| 254 |
# Ollama o descarrega sozinho. Curto = PC fraco liberado mais cedo; o modelo
|
|
@@ -273,15 +281,7 @@ MODELO_PADRAO = os.environ.get("TARS_MODELO_PADRAO", "qwen3:0.6b")
|
|
| 273 |
# Neo: o classificador por IA (qwen2.5:0.5b) saiu; quem decide o tipo do
|
| 274 |
# pedido é a helper Murph. Os ajudantes das versões antigas, se ainda
|
| 275 |
# estiverem instalados, ficam fora da escolha de conversa.
|
| 276 |
-
|
| 277 |
-
"""Modelos que não entram na escolha de conversa: os ajudantes antigos e
|
| 278 |
-
o Cooper (4.5), que só olha a tela."""
|
| 279 |
-
|
| 280 |
-
def __contains__(self, tag):
|
| 281 |
-
return set.__contains__(self, tag) or visao_lib.e_cooper(tag)
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
MODELOS_AJUDANTES = _Ajudantes({"qwen2.5:0.5b", "gemma3:270m"})
|
| 285 |
|
| 286 |
# ------------------------------------------------------------
|
| 287 |
# Timeouts de rede (segundos) — nomeados por propósito, não por valor,
|
|
@@ -390,6 +390,7 @@ _CAPACIDADES_RELEVANTES = {"tools", "vision", "thinking"}
|
|
| 390 |
PASTA_NUCLEO = Path(__file__).resolve().parent / "nucleo"
|
| 391 |
PARTES_NUCLEO = (
|
| 392 |
"registro",
|
|
|
|
| 393 |
"ollama",
|
| 394 |
"hardware",
|
| 395 |
"aplicacoes",
|
|
|
|
| 29 |
import psutil
|
| 30 |
from PIL import Image
|
| 31 |
|
| 32 |
+
import tars_motor as motor_lib
|
| 33 |
import tars_i18n as i18n
|
| 34 |
from tars_i18n import t
|
| 35 |
import tars_acessibilidade as acess
|
|
|
|
| 161 |
# CONFIGURAÇÃO
|
| 162 |
# ============================================================
|
| 163 |
|
| 164 |
+
VERSAO = "5.3"
|
| 165 |
# 3.0 Miller: o classificador Murph e o modo AUTO.
|
| 166 |
# 4.0 Endurance: o Gargantua (IA oficial); Endurance Neo: aviso de versão nova, tarefas longas.
|
| 167 |
# 4.5 Endurance: o Cooper (visão da tela), WSL e Linux sem interface gráfica.
|
|
|
|
| 172 |
# conta do Ollama e pesquisa web pelo SearXNG.
|
| 173 |
# 5.2 Ranger: Windows, macOS e outros Linux; o assistente do sistema (lançador, ações de texto, arquivos e fotos);
|
| 174 |
# Gargantua 2 retreinado com os dados da 5.2.
|
| 175 |
+
CODINOME = "Tesseract"
|
| 176 |
VERSAO_EXIBIDA = f"{VERSAO} {CODINOME}"
|
| 177 |
# Endurance Neo: sobe a cada pacote publicado (AAAAMMDDNN). O aviso de versão
|
| 178 |
# nova compara com a REVISAO do tars.py do GitHub (tars_atualizacao.py).
|
| 179 |
+
REVISAO = 2026111301
|
| 180 |
|
| 181 |
# Endereço do servidor Ollama — respeita a variável de ambiente padrão
|
| 182 |
# do próprio Ollama (OLLAMA_HOST), pra funcionar sem editar o código
|
|
|
|
| 225 |
|
| 226 |
|
| 227 |
OLLAMA_HOST = _normalizar_ollama_host(os.environ.get("OLLAMA_HOST"))
|
| 228 |
+
if not os.environ.get("OLLAMA_HOST"): # 5.3: o motor llama.cpp é o padrão (se já instalado); senão, ou se a config de dev escolheu o Ollama, o Ollama
|
| 229 |
+
try:
|
| 230 |
+
import tars_motor_local
|
| 231 |
+
if tars_motor_local.escolhido() == "llamacpp":
|
| 232 |
+
OLLAMA_HOST = tars_motor_local.garantir() or OLLAMA_HOST
|
| 233 |
+
except Exception:
|
| 234 |
+
pass
|
| 235 |
|
| 236 |
|
| 237 |
def _ollama_responde(url, espera=0.8):
|
| 238 |
try:
|
| 239 |
+
return requests.get(motor_lib.url("versao", url), timeout=espera).status_code == 200
|
| 240 |
except Exception:
|
| 241 |
return False
|
| 242 |
|
|
|
|
| 252 |
OLLAMA_HOST, OLLAMA_NO_WINDOWS = _candidato, True
|
| 253 |
break
|
| 254 |
|
| 255 |
+
OLLAMA_URL = motor_lib.url("conversa", OLLAMA_HOST)
|
| 256 |
+
OLLAMA_TAGS_URL = motor_lib.url("modelos", OLLAMA_HOST)
|
| 257 |
+
OLLAMA_SHOW_URL = motor_lib.url("detalhes", OLLAMA_HOST)
|
| 258 |
+
OLLAMA_GENERATE_URL = motor_lib.url("gerar", OLLAMA_HOST)
|
| 259 |
+
OLLAMA_PS_URL = motor_lib.url("rodando", OLLAMA_HOST)
|
| 260 |
|
| 261 |
# Quanto tempo cada modelo fica na memória (RAM/VRAM) depois do último uso: o
|
| 262 |
# Ollama o descarrega sozinho. Curto = PC fraco liberado mais cedo; o modelo
|
|
|
|
| 281 |
# Neo: o classificador por IA (qwen2.5:0.5b) saiu; quem decide o tipo do
|
| 282 |
# pedido é a helper Murph. Os ajudantes das versões antigas, se ainda
|
| 283 |
# estiverem instalados, ficam fora da escolha de conversa.
|
| 284 |
+
MODELOS_AJUDANTES = {"qwen2.5:0.5b", "gemma3:270m"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
|
| 286 |
# ------------------------------------------------------------
|
| 287 |
# Timeouts de rede (segundos) — nomeados por propósito, não por valor,
|
|
|
|
| 390 |
PASTA_NUCLEO = Path(__file__).resolve().parent / "nucleo"
|
| 391 |
PARTES_NUCLEO = (
|
| 392 |
"registro",
|
| 393 |
+
"visual",
|
| 394 |
"ollama",
|
| 395 |
"hardware",
|
| 396 |
"aplicacoes",
|
app/tars_acessibilidade.py
CHANGED
|
@@ -314,15 +314,15 @@ def achar_janela(pid=None, titulo=None, termos=(), ativa=False, ignorar_pids=())
|
|
| 314 |
# Elementos
|
| 315 |
# ------------------------------------------------------------
|
| 316 |
|
| 317 |
-
def _texto_do_no(no, papel):
|
| 318 |
Atspi = _modulo()
|
| 319 |
if papel in PAPEIS_CAMPO or papel in ("document-text", "paragraph"):
|
| 320 |
try:
|
| 321 |
if _nativo is not None:
|
| 322 |
-
return _nativo.texto(no)[:
|
| 323 |
n = Atspi.Text.get_character_count(no)
|
| 324 |
if n:
|
| 325 |
-
return Atspi.Text.get_text(no, 0, min(n,
|
| 326 |
except Exception:
|
| 327 |
pass
|
| 328 |
return _nome(no)
|
|
@@ -611,3 +611,51 @@ def preencher(janela, nome, texto):
|
|
| 611 |
except Exception:
|
| 612 |
pass
|
| 613 |
return {"sucesso": False, "achou": True, "elemento": rotulo, "precisa_digitar": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
# Elementos
|
| 315 |
# ------------------------------------------------------------
|
| 316 |
|
| 317 |
+
def _texto_do_no(no, papel, limite=LIMITE_CHARS_TEXTO):
|
| 318 |
Atspi = _modulo()
|
| 319 |
if papel in PAPEIS_CAMPO or papel in ("document-text", "paragraph"):
|
| 320 |
try:
|
| 321 |
if _nativo is not None:
|
| 322 |
+
return _nativo.texto(no)[:limite] or _nome(no)
|
| 323 |
n = Atspi.Text.get_character_count(no)
|
| 324 |
if n:
|
| 325 |
+
return Atspi.Text.get_text(no, 0, min(n, limite))
|
| 326 |
except Exception:
|
| 327 |
pass
|
| 328 |
return _nome(no)
|
|
|
|
| 611 |
except Exception:
|
| 612 |
pass
|
| 613 |
return {"sucesso": False, "achou": True, "elemento": rotulo, "precisa_digitar": True}
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
# ------------------------------------------------------------
|
| 617 |
+
# 5.3 (F4+): ler o que a janela mostra, como texto, para perguntar sobre ela SEM imagem (o texto entra no mesmo contrato das
|
| 618 |
+
# ações de texto). Nunca lê campo de senha e não guarda nada.
|
| 619 |
+
# ------------------------------------------------------------
|
| 620 |
+
LIMITE_TEXTO_JANELA = 6000 # caracteres, o mesmo corte das ações de texto
|
| 621 |
+
LIMITE_CHARS_BLOCO = 1500 # um parágrafo ou campo; o resto do bloco é cortado
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def montar_texto_da_janela(blocos, botoes, limite=LIMITE_TEXTO_JANELA):
|
| 625 |
+
"""Junta os blocos de texto da janela (na ordem de leitura, sem repetir) e, no fim, os nomes dos botões e links, para
|
| 626 |
+
perguntas como "onde clico para exportar?". Corta no limite."""
|
| 627 |
+
vistos, partes = set(), []
|
| 628 |
+
for bloco in blocos:
|
| 629 |
+
limpo = " ".join(str(bloco or "").split())
|
| 630 |
+
if limpo and limpo not in vistos:
|
| 631 |
+
vistos.add(limpo)
|
| 632 |
+
partes.append(limpo)
|
| 633 |
+
nomes = []
|
| 634 |
+
for nome in botoes:
|
| 635 |
+
limpo = " ".join(str(nome or "").split())[:60]
|
| 636 |
+
if limpo and limpo not in nomes:
|
| 637 |
+
nomes.append(limpo)
|
| 638 |
+
texto = "\n".join(partes)
|
| 639 |
+
if nomes:
|
| 640 |
+
texto += ("\n\n" if texto else "") + "Buttons and links: " + ", ".join(nomes[:60])
|
| 641 |
+
return texto[:limite]
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
def texto_da_janela(pid=None, titulo=None, limite=LIMITE_TEXTO_JANELA):
|
| 645 |
+
"""{"titulo", "app", "texto"} da janela pedida (ou da ativa, sem contar o próprio openTARS), lido pela acessibilidade; None
|
| 646 |
+
se não há acessibilidade, janela ou texto."""
|
| 647 |
+
if indisponivel():
|
| 648 |
+
return None
|
| 649 |
+
janela = achar_janela(pid=pid, titulo=titulo, ativa=not (pid or titulo), ignorar_pids=(os.getpid(),))
|
| 650 |
+
if not janela:
|
| 651 |
+
return None
|
| 652 |
+
blocos, botoes = [], []
|
| 653 |
+
for no, papel, nome, _estados, tipo in _itens(janela, forcar=True):
|
| 654 |
+
if "password" in papel: # campo de senha: nem o nome vira texto
|
| 655 |
+
continue
|
| 656 |
+
if tipo in ("texto", "campo"):
|
| 657 |
+
blocos.append(_texto_do_no(no, papel, LIMITE_CHARS_BLOCO))
|
| 658 |
+
elif tipo == "clicavel" and nome:
|
| 659 |
+
botoes.append(nome)
|
| 660 |
+
texto = montar_texto_da_janela(blocos, botoes, limite)
|
| 661 |
+
return {"titulo": janela["titulo"], "app": janela["app"], "texto": texto} if texto.strip() else None
|
app/tars_acoes_texto.py
CHANGED
|
@@ -18,6 +18,7 @@ import unicodedata
|
|
| 18 |
import requests
|
| 19 |
|
| 20 |
import tars_plataforma as plataforma
|
|
|
|
| 21 |
|
| 22 |
SISTEMA_ACAO_TEXTO = (
|
| 23 |
"You are Gargantua, the AI of openTARS. You transform the text the user selected and answer with ONLY the result: "
|
|
@@ -37,6 +38,11 @@ INSTRUCOES = {
|
|
| 37 |
"para_pt": {"pt_BR": "Traduza o texto para o português.", "en": "Translate the text into Portuguese."},
|
| 38 |
"perguntar": {"pt_BR": "Responda à pergunta usando só o texto. Se o texto não trouxer a resposta, diga isso.",
|
| 39 |
"en": "Answer the question using only the text. If the text doesn't say, say so."},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
LIMITE_TEXTO = 6000 # caracteres do trecho; mais que isso é cortado (e a janela avisa)
|
|
@@ -51,7 +57,8 @@ def pedido(acao, idioma, texto, pergunta=None):
|
|
| 51 |
"""A mensagem do usuário, no formato do treino."""
|
| 52 |
instrucao = INSTRUCOES[acao][idioma if idioma in ("pt_BR", "en") else "en"]
|
| 53 |
if pergunta:
|
| 54 |
-
|
|
|
|
| 55 |
return f"{instrucao}\n\n<texto>\n{texto}\n</texto>"
|
| 56 |
|
| 57 |
|
|
@@ -89,13 +96,15 @@ def rodar(acao, texto, modelo, host, pergunta=None, ao_pedaco=None, cancelar=Non
|
|
| 89 |
manter=False):
|
| 90 |
"""Pede ao Ollama e devolve o texto inteiro; `ao_pedaco(trecho)` recebe a resposta enquanto chega. `manter`: o assistente
|
| 91 |
está ligado, o modelo fica na memória só por 3 minutos depois do uso (keep_alive "3m") e sai sozinho."""
|
|
|
|
|
|
|
| 92 |
sessao = sessao or requests
|
| 93 |
corpo = {"model": modelo, "stream": True, "think": False, "messages": mensagens(acao, texto, pergunta, padrao),
|
| 94 |
"options": {"temperature": 0.1, "num_predict": 1200}}
|
| 95 |
if manter:
|
| 96 |
corpo["keep_alive"] = "3m"
|
| 97 |
partes = []
|
| 98 |
-
with sessao.post(
|
| 99 |
if r.status_code != 200: # o Ollama explica no corpo ("model 'x' not found"); o status sozinho não diz nada
|
| 100 |
try:
|
| 101 |
motivo = r.json().get("error")
|
|
|
|
| 18 |
import requests
|
| 19 |
|
| 20 |
import tars_plataforma as plataforma
|
| 21 |
+
import tars_motor as motor
|
| 22 |
|
| 23 |
SISTEMA_ACAO_TEXTO = (
|
| 24 |
"You are Gargantua, the AI of openTARS. You transform the text the user selected and answer with ONLY the result: "
|
|
|
|
| 38 |
"para_pt": {"pt_BR": "Traduza o texto para o português.", "en": "Translate the text into Portuguese."},
|
| 39 |
"perguntar": {"pt_BR": "Responda à pergunta usando só o texto. Se o texto não trouxer a resposta, diga isso.",
|
| 40 |
"en": "Answer the question using only the text. If the text doesn't say, say so."},
|
| 41 |
+
"curto": {"pt_BR": "Reescreva o texto de forma mais curta, mantendo as informações importantes.",
|
| 42 |
+
"en": "Rewrite the text to be shorter, keeping the important information."},
|
| 43 |
+
"lista": {"pt_BR": "Transforme o texto em uma lista de tópicos curtos.", "en": "Turn the text into a list of short bullet points."},
|
| 44 |
+
"responder": {"pt_BR": "Escreva uma resposta curta e educada a esta mensagem, dizendo o que a linha Dizer pede.",
|
| 45 |
+
"en": "Write a short, polite reply to this message that says what the Say line asks."},
|
| 46 |
}
|
| 47 |
|
| 48 |
LIMITE_TEXTO = 6000 # caracteres do trecho; mais que isso é cortado (e a janela avisa)
|
|
|
|
| 57 |
"""A mensagem do usuário, no formato do treino."""
|
| 58 |
instrucao = INSTRUCOES[acao][idioma if idioma in ("pt_BR", "en") else "en"]
|
| 59 |
if pergunta:
|
| 60 |
+
rotulo = ("Dizer", "Say") if acao == "responder" else ("Pergunta", "Question") # 5.3: responder mensagem
|
| 61 |
+
instrucao += f"\n{rotulo[0] if idioma == 'pt_BR' else rotulo[1]}: {pergunta}"
|
| 62 |
return f"{instrucao}\n\n<texto>\n{texto}\n</texto>"
|
| 63 |
|
| 64 |
|
|
|
|
| 96 |
manter=False):
|
| 97 |
"""Pede ao Ollama e devolve o texto inteiro; `ao_pedaco(trecho)` recebe a resposta enquanto chega. `manter`: o assistente
|
| 98 |
está ligado, o modelo fica na memória só por 3 minutos depois do uso (keep_alive "3m") e sai sozinho."""
|
| 99 |
+
if cancelar and cancelar.is_set(): # a pessoa já trocou de opção: nem pede ao modelo (antes o pedido velho ainda saía)
|
| 100 |
+
return ""
|
| 101 |
sessao = sessao or requests
|
| 102 |
corpo = {"model": modelo, "stream": True, "think": False, "messages": mensagens(acao, texto, pergunta, padrao),
|
| 103 |
"options": {"temperature": 0.1, "num_predict": 1200}}
|
| 104 |
if manter:
|
| 105 |
corpo["keep_alive"] = "3m"
|
| 106 |
partes = []
|
| 107 |
+
with sessao.post(motor.url("conversa", host), json=corpo, stream=True, timeout=(5, 300)) as r:
|
| 108 |
if r.status_code != 200: # o Ollama explica no corpo ("model 'x' not found"); o status sozinho não diz nada
|
| 109 |
try:
|
| 110 |
motivo = r.json().get("error")
|
app/tars_anexos.py
CHANGED
|
@@ -341,10 +341,6 @@ def montar_bloco(anexos, descricoes=None, limite_total=None):
|
|
| 341 |
return "\n\n".join(partes)
|
| 342 |
|
| 343 |
|
| 344 |
-
def imagens_b64(anexos):
|
| 345 |
-
return [a.imagem_b64 for a in anexos if a.tipo == "imagem" and a.imagem_b64]
|
| 346 |
-
|
| 347 |
-
|
| 348 |
# ------------------------------------------------------------
|
| 349 |
# Terminal: achar caminhos numa linha ("@arquivo", arrastar e soltar)
|
| 350 |
# ------------------------------------------------------------
|
|
@@ -352,45 +348,6 @@ def imagens_b64(anexos):
|
|
| 352 |
_ARROBA = re.compile(r"(?<!\S)@(\"[^\"]+\"|'[^']+'|\S+)")
|
| 353 |
|
| 354 |
|
| 355 |
-
def separar_arrobas(linha):
|
| 356 |
-
"""'resuma @notas.txt e @"meu doc.pdf"' -> ('resuma e', ['notas.txt', 'meu doc.pdf']),
|
| 357 |
-
só dos '@caminho' que existem de fato (o resto continua no texto)."""
|
| 358 |
-
achados = []
|
| 359 |
-
|
| 360 |
-
def troca(m):
|
| 361 |
-
bruto = m.group(1)
|
| 362 |
-
if bruto[0] in "\"'" and bruto[-1] == bruto[0]:
|
| 363 |
-
bruto = bruto[1:-1]
|
| 364 |
-
if bruto and _existe(normalizar_caminho(bruto)):
|
| 365 |
-
achados.append(bruto)
|
| 366 |
-
return ""
|
| 367 |
-
return m.group(0)
|
| 368 |
-
|
| 369 |
-
texto = _ARROBA.sub(troca, linha)
|
| 370 |
-
return re.sub(r"\s{2,}", " ", texto).strip(), achados
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
def linha_e_so_caminho(linha):
|
| 374 |
-
"""Quem arrasta um arquivo pro terminal cola só o caminho: se a linha
|
| 375 |
-
inteira (com ou sem aspas) é um arquivo/pasta que existe, devolve-o."""
|
| 376 |
-
t = str(linha or "").strip()
|
| 377 |
-
if not t or "\n" in t or len(t) > 1000:
|
| 378 |
-
return None
|
| 379 |
-
candidatos = [t]
|
| 380 |
-
# vários arrastados de uma vez: 'a.png' 'b.txt'
|
| 381 |
-
entre_aspas = re.findall(r"'([^']+)'|\"([^\"]+)\"", t)
|
| 382 |
-
if len(entre_aspas) > 1 and not re.sub(r"'[^']*'|\"[^\"]*\"|\s+", "", t):
|
| 383 |
-
candidatos = [a or b for a, b in entre_aspas]
|
| 384 |
-
caminhos = []
|
| 385 |
-
for c in candidatos:
|
| 386 |
-
if not (c.startswith(("/", "~", "file://", "'", '"', ".")) or ambiente.parece_caminho_windows(c)):
|
| 387 |
-
return None
|
| 388 |
-
p = normalizar_caminho(c)
|
| 389 |
-
if not _existe(p):
|
| 390 |
-
return None
|
| 391 |
-
caminhos.append(c)
|
| 392 |
-
return caminhos
|
| 393 |
-
|
| 394 |
# ------------------------------------------------------------
|
| 395 |
# Imagens citadas no texto do pedido ("veja home/pictures/foto.png")
|
| 396 |
# ------------------------------------------------------------
|
|
|
|
| 341 |
return "\n\n".join(partes)
|
| 342 |
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
# ------------------------------------------------------------
|
| 345 |
# Terminal: achar caminhos numa linha ("@arquivo", arrastar e soltar)
|
| 346 |
# ------------------------------------------------------------
|
|
|
|
| 348 |
_ARROBA = re.compile(r"(?<!\S)@(\"[^\"]+\"|'[^']+'|\S+)")
|
| 349 |
|
| 350 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
# ------------------------------------------------------------
|
| 352 |
# Imagens citadas no texto do pedido ("veja home/pictures/foto.png")
|
| 353 |
# ------------------------------------------------------------
|
app/tars_assistente.py
CHANGED
|
@@ -14,6 +14,7 @@ import requests
|
|
| 14 |
|
| 15 |
import tars_i18n as i18n
|
| 16 |
import tars_plataforma as plataforma
|
|
|
|
| 17 |
|
| 18 |
NOME_AUTOSTART = "opentars-assistente"
|
| 19 |
|
|
@@ -27,7 +28,7 @@ KEEP_ALIVE_OCIOSO = "3m" # quanto o modelo do assistente fica na memória
|
|
| 27 |
|
| 28 |
def manter_carregado(host, modelo, carregar, sessao=None):
|
| 29 |
"""Um pedido sem prompt carrega o modelo por KEEP_ALIVE_OCIOSO (e o Ollama descarrega sozinho) ou descarrega já (0)."""
|
| 30 |
-
r = (sessao or requests).post(
|
| 31 |
timeout=(5, 180))
|
| 32 |
if r.status_code != 200:
|
| 33 |
try:
|
|
|
|
| 14 |
|
| 15 |
import tars_i18n as i18n
|
| 16 |
import tars_plataforma as plataforma
|
| 17 |
+
import tars_motor as motor
|
| 18 |
|
| 19 |
NOME_AUTOSTART = "opentars-assistente"
|
| 20 |
|
|
|
|
| 28 |
|
| 29 |
def manter_carregado(host, modelo, carregar, sessao=None):
|
| 30 |
"""Um pedido sem prompt carrega o modelo por KEEP_ALIVE_OCIOSO (e o Ollama descarrega sozinho) ou descarrega já (0)."""
|
| 31 |
+
r = (sessao or requests).post(motor.url("gerar", host), json={"model": modelo, "keep_alive": KEEP_ALIVE_OCIOSO if carregar else 0},
|
| 32 |
timeout=(5, 180))
|
| 33 |
if r.status_code != 200:
|
| 34 |
try:
|
app/tars_atalhos.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""O que o assistente resolve sem o modelo (F4 do PLANO_5.3.md, 07/11): vem das camadas do openTARS Pi, onde levaram a prova
|
| 2 |
+
de 19 para 30 de 40 tarefas inéditas, e modelos maiores não ganharam do pequeno depois disso.
|
| 3 |
+
|
| 4 |
+
- estado do computador com avaliação ("está tranquilo") e capacidade ("cabe um arquivo de 10 GB?");
|
| 5 |
+
- memória: "guarda que...", "esquece...", "o que você sabe de mim?";
|
| 6 |
+
- contas;
|
| 7 |
+
- operações de arquivo com os nomes ligados ao disco e a resposta em palavras (tars_operacoes, tars_nomes);
|
| 8 |
+
- pedidos de vários passos (tars_passos);
|
| 9 |
+
- perguntas sobre o mundo respondidas por um resultado do SearXNG, com o site (tars_resposta_web).
|
| 10 |
+
|
| 11 |
+
resolver() devolve o texto da resposta, ou None: aí o pedido segue para o modelo, como antes. Os comandos passam por
|
| 12 |
+
executar_ferramenta, então valem as mesmas regras de segurança e o mesmo cartão de aprovação do resto do app.
|
| 13 |
+
As operações de arquivo só valem no Linux (os comandos são de bash e GNU); no Windows e no macOS o modelo continua com elas.
|
| 14 |
+
"""
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import shlex
|
| 18 |
+
import time
|
| 19 |
+
import unicodedata
|
| 20 |
+
|
| 21 |
+
import tars_conta
|
| 22 |
+
import tars_email
|
| 23 |
+
import tars_integracoes
|
| 24 |
+
import tars_lembretes
|
| 25 |
+
import tars_midia
|
| 26 |
+
import tars_operacoes as operacoes
|
| 27 |
+
import tars_passos as passos
|
| 28 |
+
import tars_regras_terminal as regras_terminal
|
| 29 |
+
import tars_sistema_frases as sistema_frases
|
| 30 |
+
import tars_resposta_web as resposta_web
|
| 31 |
+
from tars_atalhos_textos import t
|
| 32 |
+
|
| 33 |
+
_MARCAS_PT = re.compile(r"[ãõçáéíóúâêô]|\b(o|os|as|um|uma|de|do|da|que|qual|quanto|quanta|quem|como|onde|por|para|pra|meu|minha|tem|tenho|e|é|cria|faz|lembra|esquece|"
|
| 34 |
+
r"em|no|na|foi|isso|voce|você|eu|quando|sim|não|nao|tá|ta|está|esta|mostra|apaga|copia|move|abre)\b", re.IGNORECASE)
|
| 35 |
+
_MARCAS_EN = re.compile(r"\b(the|an|of|what|which|who|how|where|is|are|my|me|do|does|can|please|create|make|remember|forget|search|and|in|to|when|did|was|"
|
| 36 |
+
r"were|has|have|it|this|that|you|your|i|show|delete|copy|move|open|file|folder)\b", re.IGNORECASE)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def idioma_do(texto):
|
| 40 |
+
pt, en = len(_MARCAS_PT.findall(texto)), len(_MARCAS_EN.findall(texto))
|
| 41 |
+
return "en" if en > pt else "pt"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def normalizar(texto):
|
| 45 |
+
return "".join(c for c in unicodedata.normalize("NFKD", texto) if not unicodedata.combining(c)).lower().strip()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------- estado do computador ----------------
|
| 49 |
+
_DA_ASSISTENTE = re.compile(r"\b(sua memoria|your memory|na memoria|da memoria|in memory|from memory|to memory|de mim|about me)\b")
|
| 50 |
+
_MEMORIA = re.compile(r"\b(ram|memoria|memory)\b")
|
| 51 |
+
_QUANTO = re.compile(r"\b(quanta|quanto|livre|disponivel|sobrando|sobrou|usada|uso|usando|how much|free|usage|used|left|available)\b")
|
| 52 |
+
_AVALIA = re.compile(r"\b(acabando|acabou|cheia|cheio|lotad[oa]|pouca|pouco|suficiente|enough|running out|low|full|tranquil\w*|apertad[oa]|limite|limit|"
|
| 53 |
+
r"sobrecarregad[oa]|ocupad[oa]|busy|pesad[oa]|heavy|alta|alto|high|ok|bem|fine|aguenta|aguentando|handle|almost|quase)\b")
|
| 54 |
+
_DISCO = re.compile(r"\b(disco|disk|armazenamento|storage|ssd|hd|espaco livre|espaco no|espaco em disco|free space|drive)\b")
|
| 55 |
+
_TAMANHO = re.compile(r"(\d+(?:[.,]\d+)?)\s*(tb|gb|g|giga\w*|mb|m|mega\w*|kb)\b")
|
| 56 |
+
_CABE = re.compile(r"\b(da pra|da para|cabe|caberia|cabem|consigo|posso|tem espaco|tem lugar|sobra espaco|is there (?:enough )?(?:room|space|memory)|"
|
| 57 |
+
r"enough (?:space|room|memory|ram)|can i (?:download|fit|store|save|run|keep|open)|fits?|would fit|aguenta|can it (?:run|handle))\b")
|
| 58 |
+
_USA_RAM = re.compile(r"\b(ram|memoria|memory|rodar|roda|run|running|abrir|open|programa|program|processo|process|app|aplicativo|servico|service|jogo|game)\b")
|
| 59 |
+
_CPU = re.compile(r"\b(uso|carga|usage|load|busy|ocupad[oa])\b.{0,15}\b(cpu|processador|processor)\b|\b(cpu|processador|processor)\b.{0,20}"
|
| 60 |
+
r"\b(uso|carga|usage|load|ocupad[oa]|busy|sobrecarregad[oa]|pesad[oa]|aguenta\w*)\b|\b(quantos nucleos|how many (cpu )?cores)\b")
|
| 61 |
+
_LIGADO = re.compile(r"\buptime\b|\b(ha )?quanto tempo\b.{0,30}\b(ligad|rodando)|\bhow long\b.{0,30}\b(running|been on|been up|up)\b")
|
| 62 |
+
_DE_PROCESSO = re.compile(r"\b(processos?|process(es)?|programas?|apps?|aplicativos?|quem esta|what'?s|whats|which program|o que esta)\b.{0,30}"
|
| 63 |
+
r"\b(usando|comendo|gastando|consumindo|using|eating|hogging|taking)\b")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _folga(livre, total, idioma):
|
| 67 |
+
parte = livre / total if total else 1
|
| 68 |
+
return t("folga_boa" if parte > 0.3 else "folga_media" if parte > 0.1 else "folga_pouca", idioma)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _memoria_mb():
|
| 72 |
+
import psutil
|
| 73 |
+
m = psutil.virtual_memory()
|
| 74 |
+
return m.total // 2**20, m.available // 2**20 # disponível, não a coluna "free" (o cache conta como livre)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _disco_gb():
|
| 78 |
+
import shutil
|
| 79 |
+
d = shutil.disk_usage(os.path.expanduser("~"))
|
| 80 |
+
return d.total / 1e9, d.free / 1e9
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def estado(n, idioma):
|
| 84 |
+
if _DA_ASSISTENTE.search(n) or _DE_PROCESSO.search(n):
|
| 85 |
+
return None
|
| 86 |
+
tamanho = _TAMANHO.search(n)
|
| 87 |
+
if tamanho and _CABE.search(n):
|
| 88 |
+
valor = float(tamanho.group(1).replace(",", "."))
|
| 89 |
+
mb = valor * {"t": 1024 * 1024, "g": 1024, "m": 1, "k": 1 / 1024}[tamanho.group(2)[0]]
|
| 90 |
+
pedido = f"{tamanho.group(1)} {tamanho.group(2).upper()}"
|
| 91 |
+
if _USA_RAM.search(n) and not re.search(r"\b(baixar|download|salvar|save|guardar|store|disco|disk|arquivo|file)\b", n):
|
| 92 |
+
total, livre = _memoria_mb()
|
| 93 |
+
return t("cabe_ram" if mb < livre * 0.9 else "nao_cabe_ram", idioma, livre=livre, pedido=pedido)
|
| 94 |
+
total, livre = _disco_gb()
|
| 95 |
+
return t("cabe_disco" if mb < livre * 1000 * 0.95 else "nao_cabe_disco", idioma, livre=livre, pedido=pedido)
|
| 96 |
+
if _MEMORIA.search(n) and (_QUANTO.search(n) or _AVALIA.search(n)):
|
| 97 |
+
total, livre = _memoria_mb()
|
| 98 |
+
texto = t("mem", idioma, total=total, livre=livre, usada=total - livre)
|
| 99 |
+
return texto + " " + _folga(livre, total, idioma) if _AVALIA.search(n) else texto
|
| 100 |
+
if _DISCO.search(n) and (_QUANTO.search(n) or _AVALIA.search(n) or re.search(r"\b(espaco|space)\b", n)):
|
| 101 |
+
total, livre = _disco_gb()
|
| 102 |
+
texto = t("disco", idioma, total=total, livre=livre, usado=total - livre)
|
| 103 |
+
return texto + " " + _folga(livre, total, idioma) if _AVALIA.search(n) else texto
|
| 104 |
+
if _CPU.search(n):
|
| 105 |
+
import psutil
|
| 106 |
+
nucleos, carga = os.cpu_count() or 1, psutil.getloadavg()[0] # os.getloadavg não existe no Windows
|
| 107 |
+
return t("cpu", idioma, nucleos=nucleos, carga=carga) + " " + t("cpu_folga" if carga < 0.7 * nucleos else "cpu_media" if carga < nucleos else "cpu_cheia", idioma)
|
| 108 |
+
if _LIGADO.search(n):
|
| 109 |
+
import psutil
|
| 110 |
+
segundos = int(time.time() - psutil.boot_time())
|
| 111 |
+
dias, resto = divmod(segundos, 86400)
|
| 112 |
+
horas, minutos = resto // 3600, resto % 3600 // 60
|
| 113 |
+
return t("ligada", idioma, tempo=" ".join(p for p in (f"{dias} d" if dias else "", f"{horas} h" if horas else "", f"{minutos} min") if p))
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ---------------- memória da pessoa ----------------
|
| 118 |
+
_POLIDEZ = r"^(por favor,? |please,? |pode |voce pode |can you |could you |from now on,? |a partir de agora,? )?"
|
| 119 |
+
# 13/11 (revisão): "salva/grava/guarda/save" também querem dizer salvar um arquivo ou gravar a tela ("salva meu arquivo na pasta X"
|
| 120 |
+
# virava um fato na memória); esses só valem com "que", "that", ":" ou "na memória". Os que só querem dizer lembrar aceitam "meu/my" direto.
|
| 121 |
+
_LEMBRAR = re.compile(_POLIDEZ + r"(?:(lembra|lembre|anota|anote|memoriza|memorize|nao esquece|nao esqueca|remember|keep in mind|don'?t forget|"
|
| 122 |
+
r"jot down|make a note)\b( ai| isso| disso| this| na memoria| to memory)?( que| that|:|(?= (my|meu|minha|eu|i)\b))|"
|
| 123 |
+
r"(grava|grave|guarda|guarde|salva|salve|save|note|write down)\b(( ai| isso| this)? (na memoria|to memory|in memory))?"
|
| 124 |
+
r"( ai| isso| this)?( que| that|:))\s*")
|
| 125 |
+
_LEMBRAR_NO_FIM = re.compile(r"^(.+?)[,.]?\s+(keep that|remember that|remember this|guarda isso|lembra disso|anota isso|anota ai)[.!]?$")
|
| 126 |
+
_ESQUECER_TUDO = re.compile(r"^(esquece|esqueca|forget)\s+(tudo|everything)( que voce sabe( sobre mim)?| about me| you know( about me)?)?[.!]*$|"
|
| 127 |
+
r"\b(apaga|apague|limpa|limpe|zera|clear|wipe|erase|reset)\s+(toda |a |sua |your |the |all )*(memoria|memory)\b")
|
| 128 |
+
_ESQUECER = re.compile(_POLIDEZ + r"(esquece|esqueca|esquecer|forget)\b\s*(isso|that)?\s*(d[aeo]s?|que|about|sobre)?\s*(?P<alvo>.*)$")
|
| 129 |
+
_VER_MEMORIA = re.compile(r"\bo que (voce )?(lembra|guardou|sabe) (de|sobre) mim\b|\bwhat do you (remember|know)( about me)?\b|"
|
| 130 |
+
r"\b(mostra|mostre|show)( a| your)? (sua )?(memoria|memory)\b")
|
| 131 |
+
_VAZIAS = {"o", "a", "os", "as", "de", "do", "da", "que", "eu", "meu", "minha", "the", "of", "my", "i", "that", "isso", "about", "sobre", "is", "e"}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def memoria(pedido, n, idioma, core):
|
| 135 |
+
mem = core.memoria
|
| 136 |
+
if _VER_MEMORIA.search(n):
|
| 137 |
+
fatos = mem.listar()
|
| 138 |
+
return t("memoria", idioma, fatos="\n".join("- " + f for f in fatos)) if fatos else t("memoria_vazia", idioma)
|
| 139 |
+
if _ESQUECER_TUDO.search(n):
|
| 140 |
+
mem.esquecer("*")
|
| 141 |
+
return t("esqueci_tudo", idioma)
|
| 142 |
+
achado = _LEMBRAR.search(n) or None
|
| 143 |
+
if achado:
|
| 144 |
+
fato = pedido.strip()[achado.end():].strip(" .") if len(pedido.strip()) == len(n) else n[achado.end():].strip(" .")
|
| 145 |
+
ok, _ = mem.lembrar(fato) if fato else (False, None)
|
| 146 |
+
return t("anotado", idioma, fato=fato) if ok else None
|
| 147 |
+
achado = _LEMBRAR_NO_FIM.search(n)
|
| 148 |
+
if achado:
|
| 149 |
+
fato = (pedido.strip() if len(pedido.strip()) == len(n) else n)[:achado.end(1)]
|
| 150 |
+
ok, _ = mem.lembrar(fato)
|
| 151 |
+
return t("anotado", idioma, fato=fato) if ok else None
|
| 152 |
+
achado = _ESQUECER.search(n)
|
| 153 |
+
if achado and achado.group("alvo"):
|
| 154 |
+
palavras = [p for p in re.findall(r"[\w-]+", achado.group("alvo")) if p not in _VAZIAS and len(p) > 2]
|
| 155 |
+
fatos = mem.listar()
|
| 156 |
+
notas = [(sum(normalizar(p) in normalizar(f) for p in palavras), f) for f in fatos]
|
| 157 |
+
melhor = max((x for x, _ in notas), default=0)
|
| 158 |
+
if not melhor:
|
| 159 |
+
return t("nada_guardado", idioma)
|
| 160 |
+
saiu = [f for x, f in notas if x == melhor]
|
| 161 |
+
for f in saiu:
|
| 162 |
+
mem.esquecer(f)
|
| 163 |
+
return t("esqueci", idioma, fato="; ".join(saiu))
|
| 164 |
+
return None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ---------------- comandos (operações de arquivo) ----------------
|
| 168 |
+
def _rodar(core, comando):
|
| 169 |
+
"""O resultado no formato que tars_operacoes.resumir lê (o do openTARS Pi)."""
|
| 170 |
+
r = core.executar_ferramenta("execute_terminal", {"command": comando})
|
| 171 |
+
r = r if isinstance(r, dict) else {}
|
| 172 |
+
return {"success": bool(r.get("sucesso")), "exit_code": r.get("codigo"), "stdout": r.get("stdout") or "",
|
| 173 |
+
"stderr": r.get("stderr") or "", "message": r.get("mensagem") or ""}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _com_saida(texto, saida):
|
| 177 |
+
linhas = [l for l in (saida or "").rstrip().splitlines()][:40]
|
| 178 |
+
bloco = "```\n" + "\n".join(linhas) + "\n```" if linhas else ""
|
| 179 |
+
return "\n\n".join(p for p in (texto, bloco) if p)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def comando(plano, idioma, core, estado_passos):
|
| 183 |
+
"""Roda o comando de uma operação e devolve (resposta, deu_certo)."""
|
| 184 |
+
r = _rodar(core, plano["comando"])
|
| 185 |
+
estado_passos["resultado"], estado_passos["comando"] = r, plano["comando"]
|
| 186 |
+
if "did not confirm" in r["message"] or "cancelled by the user" in r["message"].lower():
|
| 187 |
+
return t("negado", idioma), False # a pessoa disse não: sem o texto em inglês do controle
|
| 188 |
+
nada_achado = plano.get("resumo") in ("ocorrencias", "arquivos_com") and r["exit_code"] == 1 and not r["stderr"].strip()
|
| 189 |
+
if not r["success"] and not nada_achado:
|
| 190 |
+
erro = (r["stderr"] or r["message"] or "?").strip().splitlines()
|
| 191 |
+
return t("falhou", idioma, erro=erro[-1] if erro else "?"), False
|
| 192 |
+
if plano.get("lugar"):
|
| 193 |
+
estado_passos["lugar"] = plano["lugar"]
|
| 194 |
+
elif plano["comando"].startswith("mkdir -p ") and "&&" not in plano["comando"]:
|
| 195 |
+
estado_passos["lugar"] = shlex.split(plano["comando"])[-1] # a pasta criada vira o "ela" do passo seguinte
|
| 196 |
+
if plano.get("alvo"):
|
| 197 |
+
estado_passos["alvo"] = plano["alvo"]
|
| 198 |
+
resumo = operacoes.resumir(plano, r, idioma)
|
| 199 |
+
if resumo:
|
| 200 |
+
return " ".join(p for p in (plano.get("nota"), resumo) if p), True
|
| 201 |
+
if not r["stdout"].strip():
|
| 202 |
+
return " ".join(p for p in (plano.get("nota"), plano.get("feito") or t("feito", idioma)) if p), True
|
| 203 |
+
return _com_saida(plano.get("nota") or "", r["stdout"]), True
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _plano(pedido, n, idioma, linux):
|
| 207 |
+
if not linux:
|
| 208 |
+
return None
|
| 209 |
+
p = operacoes.plano(pedido, n, idioma) or regras_terminal.plano(pedido, n, idioma)
|
| 210 |
+
return {"comando": p} if isinstance(p, str) else p
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _uma_parte(pedido, idioma, core, estado_passos, linux, permitir_mundo=True):
|
| 214 |
+
"""(resposta, deu_certo) de uma frase, ou None se é com o modelo."""
|
| 215 |
+
import tars_plataforma
|
| 216 |
+
n = normalizar(pedido)
|
| 217 |
+
fala = (tars_lembretes.resposta_da_rotina(pedido) or tars_lembretes.agendar(pedido, idioma) or tars_integracoes.responder(pedido, idioma)
|
| 218 |
+
or tars_midia.responder(pedido, idioma)
|
| 219 |
+
or tars_email.responder(pedido, idioma, core)
|
| 220 |
+
or memoria(pedido, n, idioma, core))
|
| 221 |
+
if fala:
|
| 222 |
+
return fala, True
|
| 223 |
+
if not linux: # 5.3: Windows e macOS: as mesmas frases, executadas direto (volume, brilho, tema, Wi-Fi, bloquear)
|
| 224 |
+
fala = sistema_frases.responder_outros(n, idioma, tars_plataforma.sistema(), core)
|
| 225 |
+
if fala:
|
| 226 |
+
return fala, True
|
| 227 |
+
plano = sistema_frases.plano(n, idioma) if linux else None
|
| 228 |
+
plano = plano or _plano(pedido, n, idioma, linux)
|
| 229 |
+
if plano:
|
| 230 |
+
if "fala" in plano:
|
| 231 |
+
return plano["fala"], True
|
| 232 |
+
return comando(plano, idioma, core, estado_passos)
|
| 233 |
+
fala = estado(n, idioma) or tars_conta.resolver(n, idioma)
|
| 234 |
+
if fala:
|
| 235 |
+
return fala, True
|
| 236 |
+
if permitir_mundo:
|
| 237 |
+
fala = mundo(pedido, n, idioma)
|
| 238 |
+
if fala:
|
| 239 |
+
return fala, True
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# ---------------- perguntas sobre o mundo ----------------
|
| 244 |
+
_PERGUNTA = re.compile(r"^(quem|qual|quais|quando|onde|quanto|quanta|quantos|quantas|em que ano|o que e|o que foi|what|who|which|when|where|how (tall|far|high|long|big|many|much|old)|"
|
| 245 |
+
r"in what year|what year)\b")
|
| 246 |
+
_PESSOAL = re.compile(r"\b(voce|seu|sua|you|your|eu|meu|minha|meus|minhas|me|mim|my|i|computador|computer|pc|arquivo|arquivos|file|files|pasta|folder|disco|disk|"
|
| 247 |
+
r"memoria|memory|ram|cpu|processador|processor|wifi|internet|placa|ip|senha|password|lembr|remind|timer|alarme|alarm)\b")
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def mundo(pedido, n, idioma):
|
| 251 |
+
"""Uma frase de um resultado do SearXNG que responde, com o site. None: sem SearXNG ligado, sem resultado bom, ou não é pergunta sobre o mundo."""
|
| 252 |
+
if not _PERGUNTA.search(n) or _PESSOAL.search(n):
|
| 253 |
+
return None
|
| 254 |
+
try:
|
| 255 |
+
import tars_web
|
| 256 |
+
if not tars_web.config()["ligada"]:
|
| 257 |
+
return None
|
| 258 |
+
achado = resposta_web.responder(pedido.strip(" ?"))
|
| 259 |
+
except Exception: # noqa: BLE001 - sem busca, o modelo responde como antes
|
| 260 |
+
return None
|
| 261 |
+
if not achado:
|
| 262 |
+
return None
|
| 263 |
+
import urllib.parse
|
| 264 |
+
site = urllib.parse.urlsplit(achado["url"]).hostname or achado["url"]
|
| 265 |
+
return t("de_onde", idioma, site=site, trecho=achado["trecho"])
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# ---------------- entrada ----------------
|
| 269 |
+
def resolver(texto, core):
|
| 270 |
+
"""A resposta sem o modelo, ou None. core: o módulo tars (memória, executar_ferramenta)."""
|
| 271 |
+
texto = (texto or "").strip()
|
| 272 |
+
if not texto or len(texto) > 400 or os.environ.get("TARS_SEM_ATALHOS"):
|
| 273 |
+
return None
|
| 274 |
+
import tars_plataforma
|
| 275 |
+
linux = tars_plataforma.sistema() == "linux"
|
| 276 |
+
idioma = idioma_do(texto)
|
| 277 |
+
estado_passos = {"lugar": None, "resultado": None, "comando": None, "alvo": None}
|
| 278 |
+
partes = passos.dividir(texto, normalizar(texto))
|
| 279 |
+
if partes:
|
| 280 |
+
falas = []
|
| 281 |
+
for i, parte in enumerate(partes):
|
| 282 |
+
n = normalizar(parte)
|
| 283 |
+
if i and passos.pede_contagem(n) and estado_passos["resultado"]:
|
| 284 |
+
saida = estado_passos["resultado"].get("stdout") or ""
|
| 285 |
+
quantos = len([l for l in saida.splitlines() if l.strip()])
|
| 286 |
+
falas.append(t("r_e_um", idioma) if quantos == 1 else t("r_sao", idioma, n=quantos))
|
| 287 |
+
continue
|
| 288 |
+
destino = passos.salvar_em(n) if i else None
|
| 289 |
+
if destino:
|
| 290 |
+
anterior = estado_passos["comando"] if (estado_passos["resultado"] or {}).get("success") else None
|
| 291 |
+
if not anterior or not linux:
|
| 292 |
+
if i == 1 and not falas:
|
| 293 |
+
return None
|
| 294 |
+
falas.append(t("nada_para_salvar", idioma))
|
| 295 |
+
break
|
| 296 |
+
comeco = n.rfind(destino)
|
| 297 |
+
destino = parte[comeco:comeco + len(destino)] if comeco >= 0 else destino
|
| 298 |
+
resposta, ok = comando({"comando": f"{anterior} > {operacoes._q(destino)}"}, idioma, core, estado_passos)
|
| 299 |
+
else:
|
| 300 |
+
parte = passos.com_lugar(parte, estado_passos["lugar"]) if i else parte
|
| 301 |
+
feito = _uma_parte(parte, idioma, core, estado_passos, linux, permitir_mundo=False)
|
| 302 |
+
if feito is None and i and estado_passos["alvo"]:
|
| 303 |
+
feito = _uma_parte(f"{parte} {'de' if idioma == 'pt' else 'of'} {estado_passos['alvo']}", idioma, core, estado_passos, linux, False)
|
| 304 |
+
if feito is None:
|
| 305 |
+
if i == 0:
|
| 306 |
+
return None # o primeiro passo não é de regra: o pedido inteiro vai para o modelo
|
| 307 |
+
falas.append(t("falhou", idioma, erro=parte))
|
| 308 |
+
break
|
| 309 |
+
resposta, ok = feito
|
| 310 |
+
if resposta and (not falas or resposta != falas[-1]):
|
| 311 |
+
falas.append(resposta)
|
| 312 |
+
if not ok:
|
| 313 |
+
break
|
| 314 |
+
return "\n".join(falas) if falas else None
|
| 315 |
+
feito = _uma_parte(texto, idioma, core, estado_passos, linux)
|
| 316 |
+
return feito[0] if feito else None
|
app/tars_atalhos_textos.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""As frases das respostas que saem sem o modelo (tars_atalhos, tars_operacoes), em pt e en: a resposta segue o idioma do pedido.
|
| 2 |
+
Vieram do openTARS Pi (opentars_pi/textos.py) em 07/11; "mem" fala da memória disponível, não da coluna "free" do `free`."""
|
| 3 |
+
|
| 4 |
+
TEXTOS = {'pt': {'r_e_um': 'É só 1.','htop_falta': 'O htop não está instalado (sudo apt install htop). Mostrando o top:','achei_em': '(Achei em {real}.)',
|
| 5 |
+
'nome_corrigido': '(Não existe {pedido}; usei {real}.)',
|
| 6 |
+
'nao_achei_arquivo': 'Não achei {nome} aqui.',
|
| 7 |
+
'nao_achei_arquivo_sugestao': 'Não achei {nome}. Você quis dizer: {sugestoes}?',
|
| 8 |
+
'apagar_parecido': 'Não existe {nome}. Você quis dizer {real}? Se for, diga: apaga {real}',
|
| 9 |
+
'nenhum_tipo': 'Não há arquivos desse tipo em {pasta}.',
|
| 10 |
+
'nao_instalado': 'O {programa} não está instalado. Para instalar: sudo apt install {pacote}',
|
| 11 |
+
'r_linhas': '{alvo} tem {n} linhas.',
|
| 12 |
+
'r_ocorrencias': '"{termo}" aparece {n} vezes em {alvo}.',
|
| 13 |
+
'r_arquivos': '{alvo} tem {n} arquivos.',
|
| 14 |
+
'r_nenhum_arquivo': 'Nenhum arquivo aqui fala de "{termo}".',
|
| 15 |
+
'r_esta_em': 'Está em: {lista}',
|
| 16 |
+
'r_maior': 'O maior arquivo de {alvo} é {nome} ({tamanho}).',
|
| 17 |
+
'r_menor': 'O menor arquivo de {alvo} é {nome} ({tamanho}).',
|
| 18 |
+
'r_tamanho': '{alvo} ocupa {tamanho}.',
|
| 19 |
+
'r_achados': 'Achei {n} em {alvo}.',
|
| 20 |
+
'r_nada_achado': 'Nada assim em {alvo}.',
|
| 21 |
+
'r_sao': 'São {n}.',
|
| 22 |
+
'r_loc': '{alvo} tem {n} linhas de código.',
|
| 23 |
+
'mem': '{livre} MB de memória disponíveis, de {total} MB ({usada} MB em uso).',
|
| 24 |
+
'disco': '{usado:.1f} GB usados no disco, {livre:.1f} GB livres, de {total:.1f} GB.',
|
| 25 |
+
'temp': 'A placa está a {temp:.0f} °C.',
|
| 26 |
+
'sem_temp': 'Esta placa não tem um sensor de temperatura que eu consiga ler.',
|
| 27 |
+
'ligada': 'Ligada há {tempo}.',
|
| 28 |
+
'cpu': '{nucleos} núcleos, carga {carga:.2f}.',
|
| 29 |
+
'processador': 'O processador é um {nome}, com {nucleos} núcleos.',
|
| 30 |
+
'folga_boa': 'Está tranquilo.',
|
| 31 |
+
'folga_media': 'Ainda dá, mas está apertando.',
|
| 32 |
+
'folga_pouca': 'Está apertado: vale liberar espaço.',
|
| 33 |
+
'cabe_disco': 'Sim: o disco tem {livre:.1f} GB livres, então {pedido} cabem.',
|
| 34 |
+
'nao_cabe_disco': 'Não: o disco tem só {livre:.1f} GB livres, não cabem {pedido}.',
|
| 35 |
+
'cabe_ram': 'Sim: há {livre} MB de memória livres, então {pedido} cabem.',
|
| 36 |
+
'nao_cabe_ram': 'Não: só há {livre} MB de memória livres, não cabem {pedido}.',
|
| 37 |
+
'temp_ok': 'Está normal.',
|
| 38 |
+
'temp_morna': 'Está morna, mas dentro do limite.',
|
| 39 |
+
'temp_quente': 'Está quente: vale olhar a ventilação.',
|
| 40 |
+
'cpu_folga': 'Está folgado.',
|
| 41 |
+
'cpu_media': 'Está ocupado, mas dando conta.',
|
| 42 |
+
'cpu_cheia': 'Está sobrecarregado agora.',
|
| 43 |
+
'anotado': 'Anotado: {fato}.',
|
| 44 |
+
'esqueci': 'Esquecido: {fato}.',
|
| 45 |
+
'esqueci_tudo': 'Pronto, minha memória está vazia.',
|
| 46 |
+
'nada_guardado': 'Não tenho nada guardado sobre isso.',
|
| 47 |
+
'memoria': 'Isto é o que eu lembro:\n{fatos}',
|
| 48 |
+
'memoria_vazia': 'Minha memória está vazia.',
|
| 49 |
+
'lembro': 'Você me disse: {fato}.',
|
| 50 |
+
'feito': 'Pronto.',
|
| 51 |
+
'falhou': 'Não deu certo: {erro}',
|
| 52 |
+
'negado': 'Certo, não rodei.',
|
| 53 |
+
'de_onde': 'Segundo {site}: {trecho}',
|
| 54 |
+
'tambem': 'Também: {site} ({titulo})',
|
| 55 |
+
'nao_achei': 'Não achei uma boa resposta. O mais perto que encontrei: {titulo} ({site}).',
|
| 56 |
+
'nada_para_salvar': 'Não tenho o que salvar do passo anterior.'},
|
| 57 |
+
'en': {'r_e_um': 'Just 1.','htop_falta': 'htop is not installed (sudo apt install htop). Here is top instead:','achei_em': '(Found it at {real}.)',
|
| 58 |
+
'nome_corrigido': '(There is no {pedido}; I used {real}.)',
|
| 59 |
+
'nao_achei_arquivo': 'I could not find {nome} here.',
|
| 60 |
+
'nao_achei_arquivo_sugestao': 'I could not find {nome}. Did you mean: {sugestoes}?',
|
| 61 |
+
'apagar_parecido': 'There is no {nome}. Did you mean {real}? If so, say: delete {real}',
|
| 62 |
+
'nenhum_tipo': 'There are no files of that kind in {pasta}.',
|
| 63 |
+
'nao_instalado': '{programa} is not installed. Install it with: sudo apt install {pacote}',
|
| 64 |
+
'r_linhas': '{alvo} has {n} lines.',
|
| 65 |
+
'r_ocorrencias': '"{termo}" appears {n} times in {alvo}.',
|
| 66 |
+
'r_arquivos': '{alvo} has {n} files.',
|
| 67 |
+
'r_nenhum_arquivo': 'No file here mentions "{termo}".',
|
| 68 |
+
'r_esta_em': 'It is in: {lista}',
|
| 69 |
+
'r_maior': 'The biggest file in {alvo} is {nome} ({tamanho}).',
|
| 70 |
+
'r_menor': 'The smallest file in {alvo} is {nome} ({tamanho}).',
|
| 71 |
+
'r_tamanho': '{alvo} takes up {tamanho}.',
|
| 72 |
+
'r_achados': 'Found {n} in {alvo}.',
|
| 73 |
+
'r_nada_achado': 'Nothing like that in {alvo}.',
|
| 74 |
+
'r_sao': 'That makes {n}.',
|
| 75 |
+
'r_loc': '{alvo} has {n} lines of code.',
|
| 76 |
+
'mem': '{livre} MB of memory available, out of {total} MB ({usada} MB in use).',
|
| 77 |
+
'disco': '{usado:.1f} GB used on the disk, {livre:.1f} GB free, out of {total:.1f} GB.',
|
| 78 |
+
'temp': 'The board is at {temp:.0f} °C.',
|
| 79 |
+
'sem_temp': 'This board has no temperature sensor I can read.',
|
| 80 |
+
'ligada': 'Running for {tempo}.',
|
| 81 |
+
'cpu': '{nucleos} cores, load {carga:.2f}.',
|
| 82 |
+
'processador': 'The processor is a {nome}, with {nucleos} cores.',
|
| 83 |
+
'folga_boa': 'Plenty of room.',
|
| 84 |
+
'folga_media': 'Still fine, but getting tighter.',
|
| 85 |
+
'folga_pouca': 'That is tight: worth freeing some up.',
|
| 86 |
+
'cabe_disco': 'Yes: the disk has {livre:.1f} GB free, so {pedido} fits.',
|
| 87 |
+
'nao_cabe_disco': 'No: the disk has only {livre:.1f} GB free, not enough for {pedido}.',
|
| 88 |
+
'cabe_ram': 'Yes: {livre} MB of memory are free, so {pedido} fits.',
|
| 89 |
+
'nao_cabe_ram': 'No: only {livre} MB of memory are free, not enough for {pedido}.',
|
| 90 |
+
'temp_ok': 'That is normal.',
|
| 91 |
+
'temp_morna': 'Warm, but within limits.',
|
| 92 |
+
'temp_quente': 'That is hot: check the cooling.',
|
| 93 |
+
'cpu_folga': 'It is relaxed.',
|
| 94 |
+
'cpu_media': 'Busy, but coping.',
|
| 95 |
+
'cpu_cheia': 'It is overloaded right now.',
|
| 96 |
+
'anotado': 'Noted: {fato}.',
|
| 97 |
+
'esqueci': 'Forgotten: {fato}.',
|
| 98 |
+
'esqueci_tudo': 'Done, my memory is empty now.',
|
| 99 |
+
'nada_guardado': 'I have nothing saved about that.',
|
| 100 |
+
'memoria': 'Here is what I remember:\n{fatos}',
|
| 101 |
+
'memoria_vazia': 'My memory is empty.',
|
| 102 |
+
'lembro': 'You told me: {fato}.',
|
| 103 |
+
'feito': 'Done.',
|
| 104 |
+
'falhou': 'That did not work: {erro}',
|
| 105 |
+
'negado': 'Okay, I did not run it.',
|
| 106 |
+
'de_onde': 'From {site}: {trecho}',
|
| 107 |
+
'tambem': 'Also: {site} ({titulo})',
|
| 108 |
+
'nao_achei': 'I could not find a good answer. The closest I found: {titulo} ({site}).',
|
| 109 |
+
'nada_para_salvar': 'I have nothing to save from the step before.'}}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def t(chave, idioma, **campos):
|
| 113 |
+
texto = TEXTOS["pt" if str(idioma).startswith("pt") else "en"][chave]
|
| 114 |
+
return texto.format(**campos) if campos else texto
|
app/tars_atualizacao.py
CHANGED
|
@@ -58,6 +58,16 @@ def pelo_script():
|
|
| 58 |
return plataforma.sistema() != "linux" or MARCA_SCRIPT.exists()
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def versao_publicada(agora=None, buscar=None, cache=None):
|
| 62 |
"""A versão publicada (do cache de até 24 h, ou buscada agora), ou None."""
|
| 63 |
if _desligado():
|
|
@@ -153,21 +163,47 @@ def _estado_arquivo():
|
|
| 153 |
return Path(os.environ.get("TARS_ARQUIVO_ATUALIZACAO") or os.path.expanduser("~/.config/opentars/atualizacao.json"))
|
| 154 |
|
| 155 |
|
| 156 |
-
def marcar_instalada(
|
| 157 |
-
"""Guarda
|
|
|
|
|
|
|
|
|
|
| 158 |
try:
|
| 159 |
arq = _estado_arquivo()
|
| 160 |
arq.parent.mkdir(parents=True, exist_ok=True)
|
| 161 |
-
arq.write_text(json.dumps({"sha256":
|
| 162 |
except Exception:
|
| 163 |
pass
|
| 164 |
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
def _ultima_instalada():
|
|
|
|
| 167 |
try:
|
| 168 |
-
|
| 169 |
except Exception:
|
| 170 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
|
| 173 |
def _epoch(iso):
|
|
@@ -238,8 +274,7 @@ def procurar_no_repositorio(obter_json=None, extras=(), compilado=None, ultima=N
|
|
| 238 |
blob = str(arq.get("sha") or "").lower()
|
| 239 |
if not url or not host_permitido(url, extras) or not re.fullmatch(r"[0-9a-f]{40}", blob):
|
| 240 |
return None
|
| 241 |
-
|
| 242 |
-
if blob == instalada:
|
| 243 |
return None
|
| 244 |
try:
|
| 245 |
commits = obter_json(URL_COMMITS + "&path=" + quote(str(arq.get("name"))))
|
|
@@ -297,7 +332,7 @@ def procurar_em_release(versao_local, obter_json=None, obter_texto=None, extras=
|
|
| 297 |
except Exception:
|
| 298 |
somas = {}
|
| 299 |
meu_pacote = compilado_em() if compilado is None else compilado
|
| 300 |
-
|
| 301 |
arquivos, mais_recente = {}, 0.0
|
| 302 |
for nome, a in ativos.items():
|
| 303 |
tipo = "appimage" if str(nome).endswith(".AppImage") else "deb" if str(nome).endswith(".deb") else None
|
|
@@ -309,7 +344,7 @@ def procurar_em_release(versao_local, obter_json=None, obter_texto=None, extras=
|
|
| 309 |
continue
|
| 310 |
enviado = _epoch(a.get("created_at") or a.get("updated_at"))
|
| 311 |
novo = (nova_versao > local) or (enviado > meu_pacote + 60)
|
| 312 |
-
if novo and sha
|
| 313 |
arquivos[tipo] = {"nome": nome, "url": url, "sha256": sha, "id": sha}
|
| 314 |
mais_recente = max(mais_recente, enviado)
|
| 315 |
if not arquivos:
|
|
|
|
| 58 |
return plataforma.sistema() != "linux" or MARCA_SCRIPT.exists()
|
| 59 |
|
| 60 |
|
| 61 |
+
def argumentos_um_clique(sistema=None):
|
| 62 |
+
"""5.3 (F5): o "Atualizar" de quem instalou por script roda o próprio instalador. No Windows abre o PowerShell visível (o
|
| 63 |
+
instalador fecha o app aberto e o reabre no fim); no macOS e nos outros Linux o script roda escondido e o app reinicia."""
|
| 64 |
+
sistema = sistema or plataforma.sistema()
|
| 65 |
+
if sistema == "windows":
|
| 66 |
+
reabrir = '; & (Join-Path $env:LOCALAPPDATA "openTARS\\bin\\opentars-gui.cmd")'
|
| 67 |
+
return ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", COMANDO_REINSTALAR["windows"] + reabrir]
|
| 68 |
+
return ["bash", "-c", "set -o pipefail; " + COMANDO_REINSTALAR[sistema]]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
def versao_publicada(agora=None, buscar=None, cache=None):
|
| 72 |
"""A versão publicada (do cache de até 24 h, ou buscada agora), ou None."""
|
| 73 |
if _desligado():
|
|
|
|
| 163 |
return Path(os.environ.get("TARS_ARQUIVO_ATUALIZACAO") or os.path.expanduser("~/.config/opentars/atualizacao.json"))
|
| 164 |
|
| 165 |
|
| 166 |
+
def marcar_instalada(*ids):
|
| 167 |
+
"""Guarda os identificadores do arquivo que o usuário instalou por aqui (para não oferecer o mesmo de novo). O mesmo .deb
|
| 168 |
+
chega por dois caminhos, o upload no repositório (hash git) e a Release (SHA-256); guardando só um, o outro caminho achava
|
| 169 |
+
o arquivo "novo" e oferecia a atualização para sempre (10/11), então vão todos."""
|
| 170 |
+
ids = [str(i).lower() for i in ids if i]
|
| 171 |
try:
|
| 172 |
arq = _estado_arquivo()
|
| 173 |
arq.parent.mkdir(parents=True, exist_ok=True)
|
| 174 |
+
arq.write_text(json.dumps({"sha256": ids[0] if ids else "", "ids": sorted(set(ids))}), encoding="utf-8")
|
| 175 |
except Exception:
|
| 176 |
pass
|
| 177 |
|
| 178 |
|
| 179 |
+
def ids_do_arquivo(caminho):
|
| 180 |
+
"""[sha256, hash git] de um arquivo, os dois identificadores com que ele aparece no GitHub; [] se não der para ler."""
|
| 181 |
+
try:
|
| 182 |
+
dados = Path(caminho).read_bytes()
|
| 183 |
+
except Exception:
|
| 184 |
+
return []
|
| 185 |
+
return [hashlib.sha256(dados).hexdigest(), hashlib.sha1(b"blob %d\0" % len(dados) + dados).hexdigest()]
|
| 186 |
+
|
| 187 |
+
|
| 188 |
def _ultima_instalada():
|
| 189 |
+
"""Os identificadores já instalados (conjunto)."""
|
| 190 |
try:
|
| 191 |
+
dados = json.loads(_estado_arquivo().read_text(encoding="utf-8"))
|
| 192 |
except Exception:
|
| 193 |
+
return set()
|
| 194 |
+
ids = {str(i).lower() for i in dados.get("ids") or []}
|
| 195 |
+
if dados.get("sha256"):
|
| 196 |
+
ids.add(str(dados["sha256"]).lower())
|
| 197 |
+
return ids
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _ids_instalados(valor):
|
| 201 |
+
"""`valor` None = o que está guardado; texto ou lista = o que o chamador (os testes) passou."""
|
| 202 |
+
if valor is None:
|
| 203 |
+
return _ultima_instalada()
|
| 204 |
+
if isinstance(valor, str):
|
| 205 |
+
return {valor.lower()} if valor else set()
|
| 206 |
+
return {str(i).lower() for i in valor if i}
|
| 207 |
|
| 208 |
|
| 209 |
def _epoch(iso):
|
|
|
|
| 274 |
blob = str(arq.get("sha") or "").lower()
|
| 275 |
if not url or not host_permitido(url, extras) or not re.fullmatch(r"[0-9a-f]{40}", blob):
|
| 276 |
return None
|
| 277 |
+
if blob in _ids_instalados(ultima):
|
|
|
|
| 278 |
return None
|
| 279 |
try:
|
| 280 |
commits = obter_json(URL_COMMITS + "&path=" + quote(str(arq.get("name"))))
|
|
|
|
| 332 |
except Exception:
|
| 333 |
somas = {}
|
| 334 |
meu_pacote = compilado_em() if compilado is None else compilado
|
| 335 |
+
instaladas = _ids_instalados(ultima)
|
| 336 |
arquivos, mais_recente = {}, 0.0
|
| 337 |
for nome, a in ativos.items():
|
| 338 |
tipo = "appimage" if str(nome).endswith(".AppImage") else "deb" if str(nome).endswith(".deb") else None
|
|
|
|
| 344 |
continue
|
| 345 |
enviado = _epoch(a.get("created_at") or a.get("updated_at"))
|
| 346 |
novo = (nova_versao > local) or (enviado > meu_pacote + 60)
|
| 347 |
+
if novo and sha not in instaladas:
|
| 348 |
arquivos[tipo] = {"nome": nome, "url": url, "sha256": sha, "id": sha}
|
| 349 |
mais_recente = max(mais_recente, enviado)
|
| 350 |
if not arquivos:
|
app/tars_autoteste.py
CHANGED
|
@@ -64,7 +64,7 @@ class _Relatorio:
|
|
| 64 |
|
| 65 |
def _versao_ollama(core):
|
| 66 |
try:
|
| 67 |
-
r = core._SESSION.get(
|
| 68 |
return r.json().get("version") if r.status_code == 200 else None
|
| 69 |
except Exception:
|
| 70 |
return None
|
|
|
|
| 64 |
|
| 65 |
def _versao_ollama(core):
|
| 66 |
try:
|
| 67 |
+
r = core._SESSION.get(core.motor_lib.url("versao", core.OLLAMA_HOST), timeout=core.TIMEOUT_HTTP_CURTO)
|
| 68 |
return r.json().get("version") if r.status_code == 200 else None
|
| 69 |
except Exception:
|
| 70 |
return None
|
app/tars_busca_arquivos.py
CHANGED
|
@@ -6,12 +6,16 @@ Só leitura: não abre, não move, não apaga. Igual nos três sistemas; só bib
|
|
| 6 |
import os
|
| 7 |
import re
|
| 8 |
import shutil
|
|
|
|
| 9 |
import subprocess
|
| 10 |
import time
|
| 11 |
import unicodedata
|
| 12 |
import zipfile
|
| 13 |
from pathlib import Path
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
VAZIAS = set("""o a os as um uma uns umas de da do das dos e em no na nos nas para pra por com que meu minha meus minhas
|
| 16 |
onde esta está esse essa este aquele aquela ao aos qual quais tem sobre ache acha achar encontre encontrar procure procurar
|
| 17 |
busque buscar busca arquivo arquivos documento documentos pasta the an of in on for my where is are file files find search
|
|
@@ -80,7 +84,144 @@ def _trecho(texto, palavras, largura=140):
|
|
| 80 |
return ("…" if inicio else "") + plano[inicio:inicio + largura].strip() + "…"
|
| 81 |
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
"""[{caminho, nome, pasta, modificado, trecho, sem_texto, pontos}] do melhor para o pior. Um arquivo entra quando
|
| 85 |
tem pelo menos metade das palavras da frase no nome, na pasta ou no texto."""
|
| 86 |
palavras = termos(frase)
|
|
|
|
| 6 |
import os
|
| 7 |
import re
|
| 8 |
import shutil
|
| 9 |
+
import sqlite3
|
| 10 |
import subprocess
|
| 11 |
import time
|
| 12 |
import unicodedata
|
| 13 |
import zipfile
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
+
import tars_i18n
|
| 17 |
+
import tars_plataforma as plataforma
|
| 18 |
+
|
| 19 |
VAZIAS = set("""o a os as um uma uns umas de da do das dos e em no na nos nas para pra por com que meu minha meus minhas
|
| 20 |
onde esta está esse essa este aquele aquela ao aos qual quais tem sobre ache acha achar encontre encontrar procure procurar
|
| 21 |
busque buscar busca arquivo arquivos documento documentos pasta the an of in on for my where is are file files find search
|
|
|
|
| 84 |
return ("…" if inicio else "") + plano[inicio:inicio + largura].strip() + "…"
|
| 85 |
|
| 86 |
|
| 87 |
+
# ---------------------------------------------------------------- índice local (5.3, F4+): opcional, desligado por padrão
|
| 88 |
+
# Um arquivo SQLite na pasta de dados do openTARS com o nome, a pasta e o começo do texto de cada arquivo, para a busca ser
|
| 89 |
+
# instantânea em pastas grandes. Fica só neste computador, nunca é enviado, e se apaga ao desligar. Sem embeddings: continua
|
| 90 |
+
# sendo nome e palavras, como a busca sem índice.
|
| 91 |
+
TEXTO_NO_INDICE = 8000 # caracteres do começo de cada arquivo
|
| 92 |
+
VALIDADE_INDICE_S = 3600 # a busca atualiza o índice (em segundo plano) se ele tem mais de uma hora
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def indice_ligado():
|
| 96 |
+
return bool(tars_i18n.ler_config().get("indice_arquivos"))
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def caminho_do_indice():
|
| 100 |
+
return plataforma.pastas()["dados"] / "indice-arquivos.db"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def ligar_indice(ligado):
|
| 104 |
+
"""Liga ou desliga. Desligar apaga o arquivo do índice: nada fica guardado."""
|
| 105 |
+
tars_i18n.salvar_config(indice_arquivos=bool(ligado))
|
| 106 |
+
if not ligado:
|
| 107 |
+
try:
|
| 108 |
+
caminho_do_indice().unlink()
|
| 109 |
+
except OSError:
|
| 110 |
+
pass
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _abrir_indice(caminho):
|
| 114 |
+
caminho.parent.mkdir(parents=True, exist_ok=True)
|
| 115 |
+
banco = sqlite3.connect(str(caminho), timeout=10)
|
| 116 |
+
banco.execute("CREATE TABLE IF NOT EXISTS arquivos (caminho TEXT PRIMARY KEY, mtime REAL, nome TEXT, texto TEXT, busca TEXT)")
|
| 117 |
+
banco.execute("CREATE TABLE IF NOT EXISTS meta (chave TEXT PRIMARY KEY, valor TEXT)")
|
| 118 |
+
return banco
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def atualizar_indice(raiz=None, tempo=25.0, ler=texto_do_arquivo, caminho=None):
|
| 122 |
+
"""Põe no índice os arquivos novos ou mudados (por data de modificação) e tira os que sumiram. Devolve
|
| 123 |
+
{"arquivos": n, "lidos": n, "removidos": n}. Corta no tempo: o que não deu volta na próxima atualização."""
|
| 124 |
+
global LIMITE_TEMPO_S
|
| 125 |
+
raiz = Path(raiz or Path.home())
|
| 126 |
+
banco = _abrir_indice(caminho or caminho_do_indice())
|
| 127 |
+
inicio = time.monotonic()
|
| 128 |
+
guardados = {c: m for c, m in banco.execute("SELECT caminho, mtime FROM arquivos")}
|
| 129 |
+
vistos, lidos = set(), 0
|
| 130 |
+
antes, LIMITE_TEMPO_S = LIMITE_TEMPO_S, tempo # a varredura do índice pode demorar mais que a busca sem índice
|
| 131 |
+
try:
|
| 132 |
+
for arquivo in _arquivos(raiz, inicio):
|
| 133 |
+
chave = str(arquivo)
|
| 134 |
+
try:
|
| 135 |
+
st = arquivo.stat()
|
| 136 |
+
except OSError:
|
| 137 |
+
continue
|
| 138 |
+
vistos.add(chave)
|
| 139 |
+
if guardados.get(chave) == st.st_mtime:
|
| 140 |
+
continue
|
| 141 |
+
texto = ""
|
| 142 |
+
if arquivo.suffix.lower() in LEGIVEIS and st.st_size <= LIMITE_TAMANHO:
|
| 143 |
+
texto = (ler(arquivo) or "")[:TEXTO_NO_INDICE * 2]
|
| 144 |
+
texto = " ".join(texto.split())[:TEXTO_NO_INDICE]
|
| 145 |
+
pastas = " ".join(arquivo.relative_to(raiz).parts[:-1])
|
| 146 |
+
banco.execute("INSERT OR REPLACE INTO arquivos VALUES (?,?,?,?,?)",
|
| 147 |
+
(chave, st.st_mtime, arquivo.name, texto, normalizar(f"{arquivo.stem} {pastas} {texto}")))
|
| 148 |
+
lidos += 1
|
| 149 |
+
finally:
|
| 150 |
+
LIMITE_TEMPO_S = antes
|
| 151 |
+
completo = time.monotonic() - inicio < tempo
|
| 152 |
+
sumiram = [c for c in guardados if c not in vistos] if completo else [] # só apaga se a varredura foi até o fim
|
| 153 |
+
banco.executemany("DELETE FROM arquivos WHERE caminho=?", [(c,) for c in sumiram])
|
| 154 |
+
banco.execute("INSERT OR REPLACE INTO meta VALUES ('atualizado', ?)", (str(time.time()),))
|
| 155 |
+
banco.commit()
|
| 156 |
+
total = banco.execute("SELECT COUNT(*) FROM arquivos").fetchone()[0]
|
| 157 |
+
banco.close()
|
| 158 |
+
return {"arquivos": total, "lidos": lidos, "removidos": len(sumiram)}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def indice_idade(caminho=None):
|
| 162 |
+
"""Segundos desde a última atualização, ou None se não há índice."""
|
| 163 |
+
caminho = caminho or caminho_do_indice()
|
| 164 |
+
if not caminho.exists():
|
| 165 |
+
return None
|
| 166 |
+
try:
|
| 167 |
+
banco = _abrir_indice(caminho)
|
| 168 |
+
linha = banco.execute("SELECT valor FROM meta WHERE chave='atualizado'").fetchone()
|
| 169 |
+
banco.close()
|
| 170 |
+
return time.time() - float(linha[0]) if linha else None
|
| 171 |
+
except (sqlite3.Error, ValueError):
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def buscar_no_indice(frase, raiz=None, limite=5, caminho=None):
|
| 176 |
+
"""O mesmo resultado de buscar(), lido do índice (instantâneo). None se não há índice para usar."""
|
| 177 |
+
palavras = termos(frase)
|
| 178 |
+
if not palavras:
|
| 179 |
+
return []
|
| 180 |
+
caminho = caminho or caminho_do_indice()
|
| 181 |
+
if indice_idade(caminho) is None:
|
| 182 |
+
return None
|
| 183 |
+
raiz = str(Path(raiz or Path.home()))
|
| 184 |
+
banco = _abrir_indice(caminho)
|
| 185 |
+
try:
|
| 186 |
+
filtro = " OR ".join("busca LIKE ?" for _ in palavras)
|
| 187 |
+
linhas = banco.execute(f"SELECT caminho, mtime, nome, texto, busca FROM arquivos WHERE caminho LIKE ? AND ({filtro})",
|
| 188 |
+
[raiz.rstrip(os.sep) + os.sep + "%"] + [f"%{p}%" for p in palavras]).fetchall()
|
| 189 |
+
finally:
|
| 190 |
+
banco.close()
|
| 191 |
+
minimo = max(1, (len(palavras) + 1) // 2)
|
| 192 |
+
achados = []
|
| 193 |
+
for arquivo, modificado, nome, texto, busca_n in linhas:
|
| 194 |
+
stem = normalizar(Path(nome).stem)
|
| 195 |
+
pasta_n = normalizar(" ".join(Path(arquivo).relative_to(raiz).parts[:-1]))
|
| 196 |
+
no_nome = {p for p in palavras if p in stem}
|
| 197 |
+
na_pasta = {p for p in palavras if p in pasta_n} - no_nome
|
| 198 |
+
no_texto = {p for p in palavras if texto and p in normalizar(texto)} - no_nome - na_pasta
|
| 199 |
+
if len(no_nome | na_pasta | no_texto) < minimo:
|
| 200 |
+
continue
|
| 201 |
+
achados.append({"caminho": arquivo, "nome": nome, "pasta": str(Path(arquivo).parent), "modificado": modificado,
|
| 202 |
+
"trecho": _trecho(texto, palavras) if texto else "",
|
| 203 |
+
"sem_texto": nome.lower().endswith(".pdf") and not texto.strip(),
|
| 204 |
+
"pontos": 3 * len(no_nome) + 2 * len(na_pasta) + len(no_texto)})
|
| 205 |
+
achados.sort(key=lambda a: (-a["pontos"], -a["modificado"]))
|
| 206 |
+
return achados[:limite]
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def buscar(frase, raiz=None, limite=5, ler=texto_do_arquivo, indice=None):
|
| 210 |
+
"""Com o índice ligado e existente, lê dele (e, se ele tem mais de uma hora, o atualiza em segundo plano); senão anda pelas
|
| 211 |
+
pastas como na 5.2. `indice`: True/False força; None segue a configuração."""
|
| 212 |
+
usar = indice_ligado() if indice is None else indice
|
| 213 |
+
if usar:
|
| 214 |
+
achados = buscar_no_indice(frase, raiz, limite)
|
| 215 |
+
if achados is not None:
|
| 216 |
+
idade = indice_idade()
|
| 217 |
+
if idade is not None and idade > VALIDADE_INDICE_S:
|
| 218 |
+
import threading
|
| 219 |
+
threading.Thread(target=lambda: atualizar_indice(raiz), daemon=True).start()
|
| 220 |
+
return achados
|
| 221 |
+
return _buscar_andando(frase, raiz, limite, ler)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _buscar_andando(frase, raiz=None, limite=5, ler=texto_do_arquivo):
|
| 225 |
"""[{caminho, nome, pasta, modificado, trecho, sem_texto, pontos}] do melhor para o pior. Um arquivo entra quando
|
| 226 |
tem pelo menos metade das palavras da frase no nome, na pasta ou no texto."""
|
| 227 |
palavras = termos(frase)
|
app/tars_confianca.py
CHANGED
|
@@ -21,6 +21,9 @@ _NAO_E_FATO = re.compile( # pedido criativo ou de texto, em qualquer posição
|
|
| 21 |
r"\b(escreva|escreve|poema|poesia|conto|historia|cancao|musica|letra|verso|rima|piada|crie|cria|invente|inventa|"
|
| 22 |
r"faca|faz|gere|gera|sugira|traduza|traduz|resuma|resume|reescreva|corrija|"
|
| 23 |
r"write|poem|poetry|story|joke|song|lyrics|translate|summari[sz]e|rewrite|fix|improve|draft|make up|tell me)\b", re.I)
|
|
|
|
|
|
|
|
|
|
| 24 |
_PERGUNTA = re.compile( # a forma de pergunta de fato: termina com "?" ou começa com palavra interrogativa
|
| 25 |
r"\?\s*$|^(quem|qual|quais|quando|quanto|quanta|quantos|quantas|onde|por que|porque|o que|que|como|existe|"
|
| 26 |
r"who|what|when|where|which|how|is|are|does|did)\b")
|
|
@@ -55,7 +58,8 @@ def vale_conferir(tarefa, pedido, resposta):
|
|
| 55 |
if len((resposta or "").strip()) < MIN_CARACTERES or "```" in (resposta or ""):
|
| 56 |
return False
|
| 57 |
pedido = _sem_acento(pedido)
|
| 58 |
-
return len(pedido.split()) >= 3 and _PERGUNTA.search(pedido) is not None and not _NAO_E_FATO.search(pedido)
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
def logprobs_do_pedaco(pedaco):
|
|
|
|
| 21 |
r"\b(escreva|escreve|poema|poesia|conto|historia|cancao|musica|letra|verso|rima|piada|crie|cria|invente|inventa|"
|
| 22 |
r"faca|faz|gere|gera|sugira|traduza|traduz|resuma|resume|reescreva|corrija|"
|
| 23 |
r"write|poem|poetry|story|joke|song|lyrics|translate|summari[sz]e|rewrite|fix|improve|draft|make up|tell me)\b", re.I)
|
| 24 |
+
_SAUDACAO = re.compile( # "e aí, tudo certo?" tem forma de pergunta, mas o app mandava pesquisar: a prova de 100 cobrava isso do modelo
|
| 25 |
+
r"^(e ai|oi|ola|opa|fala|bom dia|boa tarde|boa noite|tudo bem|tudo certo|como vai|como voce esta|"
|
| 26 |
+
r"hey|hi|hello|good (morning|afternoon|evening)|how are you|how's it going)\b", re.I)
|
| 27 |
_PERGUNTA = re.compile( # a forma de pergunta de fato: termina com "?" ou começa com palavra interrogativa
|
| 28 |
r"\?\s*$|^(quem|qual|quais|quando|quanto|quanta|quantos|quantas|onde|por que|porque|o que|que|como|existe|"
|
| 29 |
r"who|what|when|where|which|how|is|are|does|did)\b")
|
|
|
|
| 58 |
if len((resposta or "").strip()) < MIN_CARACTERES or "```" in (resposta or ""):
|
| 59 |
return False
|
| 60 |
pedido = _sem_acento(pedido)
|
| 61 |
+
return len(pedido.split()) >= 3 and _PERGUNTA.search(pedido) is not None and not _NAO_E_FATO.search(pedido) \
|
| 62 |
+
and not _SAUDACAO.search(pedido)
|
| 63 |
|
| 64 |
|
| 65 |
def logprobs_do_pedaco(pedaco):
|
app/tars_conta.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contas por código: o modelo de 270M erra aritmética e a busca devolve lixo ("square root of 128" trazia a Wikipédia)."""
|
| 2 |
+
import ast
|
| 3 |
+
import math
|
| 4 |
+
import operator
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
_PEDIDO = re.compile(r"^(please |por favor |pode |can you |could you )?(what is|what's|whats|how much is|how much|calculate|calc|compute|solve|work out|"
|
| 8 |
+
r"quanto e|quanto da|quanto vale|qual e|qual o valor de|qual|calcula|calcule|calcular|resolve|resolva|conta)?\s*(a |o |the )?")
|
| 9 |
+
_PALAVRAS = [
|
| 10 |
+
(r"\braiz quadrada d[eao]\s*", "sqrt "), (r"\bsquare root of\s*", "sqrt "), (r"\braiz de\s*", "sqrt "), (r"\bsqrt( of)?\s*", "sqrt "),
|
| 11 |
+
(r"\bmetade d[eao]\s*", "0.5*"), (r"\bhalf of\s*", "0.5*"), (r"\bdobro d[eao]\s*", "2*"), (r"\bdouble of\s*", "2*"), (r"\bdouble\s+(?=\d)", "2*"), (r"\btriplo d[eao]\s*", "3*"),
|
| 12 |
+
(r"(\d)\s*(%|por cento|percent)\s*(de|do|da|of)\s*", r"\1*0.01*"),
|
| 13 |
+
(r"\b(elevado a|elevado ao?|to the power of|to the)\s*", "**"), (r"\bao quadrado\b|\bsquared\b", "**2"), (r"\bao cubo\b|\bcubed\b", "**3"), (r"\^", "**"),
|
| 14 |
+
(r"\b(vezes|times|multiplicado por|multiplied by)\b", "*"), (r"(?<=\d)\s*x\s*(?=\d)", "*"),
|
| 15 |
+
(r"\b(dividido por|divided by|over)\b", "/"), (r"\b(mais|plus)\b", "+"), (r"\b(menos|minus)\b", "-"),
|
| 16 |
+
]
|
| 17 |
+
_OPERACOES = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Mod: operator.mod}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _valor(no):
|
| 21 |
+
if isinstance(no, ast.Constant) and type(no.value) in (int, float):
|
| 22 |
+
return no.value
|
| 23 |
+
if isinstance(no, ast.UnaryOp) and isinstance(no.op, (ast.USub, ast.UAdd)):
|
| 24 |
+
return -_valor(no.operand) if isinstance(no.op, ast.USub) else _valor(no.operand)
|
| 25 |
+
if isinstance(no, ast.BinOp) and isinstance(no.op, ast.Pow):
|
| 26 |
+
base, expoente = _valor(no.left), _valor(no.right)
|
| 27 |
+
if abs(expoente) > 300 or abs(base) > 1e6: # 9**9**9 prenderia a placa
|
| 28 |
+
raise ValueError
|
| 29 |
+
return base ** expoente
|
| 30 |
+
if isinstance(no, ast.BinOp) and type(no.op) in _OPERACOES:
|
| 31 |
+
return _OPERACOES[type(no.op)](_valor(no.left), _valor(no.right))
|
| 32 |
+
if isinstance(no, ast.Call) and getattr(no.func, "id", "") == "sqrt" and len(no.args) == 1:
|
| 33 |
+
return math.sqrt(_valor(no.args[0]))
|
| 34 |
+
raise ValueError
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _numero(valor, idioma):
|
| 38 |
+
if isinstance(valor, float) and valor.is_integer() and abs(valor) < 1e15:
|
| 39 |
+
valor = int(valor)
|
| 40 |
+
texto = str(valor) if isinstance(valor, int) else f"{valor:.6g}" if abs(valor) >= 1e6 else f"{valor:.6f}".rstrip("0")
|
| 41 |
+
return texto.replace(".", ",") if idioma == "pt" else texto
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def resolver(n, idioma):
|
| 45 |
+
"""'2 + 2 = 4' para um pedido normalizado que é só uma conta; None para o resto."""
|
| 46 |
+
texto = _PEDIDO.sub("", n.strip(" ?.!=")).strip(" ?.!=")
|
| 47 |
+
for padrao, troca in _PALAVRAS:
|
| 48 |
+
texto = re.sub(padrao, troca, texto)
|
| 49 |
+
texto = re.sub(r"(?<=\d),(?=\d)", ".", texto)
|
| 50 |
+
texto = re.sub(r"sqrt\s*(\d+(?:\.\d+)?)", r"sqrt(\1)", texto).strip()
|
| 51 |
+
if not re.search(r"\d", texto) or not re.search(r"[-+*/%]|sqrt", texto) or re.fullmatch(r"[\d/.-]+", texto) and re.search(r"\d[/-]\d+[/-]\d", texto):
|
| 52 |
+
return None # sem operação, ou uma data
|
| 53 |
+
if re.search(r"[^\d\s+\-*/%().sqrt]", texto):
|
| 54 |
+
return None
|
| 55 |
+
try:
|
| 56 |
+
return _numero(_valor(ast.parse(texto, mode="eval").body), idioma)
|
| 57 |
+
except (ValueError, SyntaxError, ZeroDivisionError, OverflowError, TypeError):
|
| 58 |
+
return None
|
app/tars_email.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.3 (F4+++): e-mail no assistente, DESLIGADO por padrão: só vale depois de a pessoa pôr a conta em Configurações > Assistente.
|
| 2 |
+
|
| 3 |
+
- A senha vai para o cofre do sistema (Secret Service no Linux, Gerenciador de Credenciais no Windows, Chaveiro no macOS) pelo pacote
|
| 4 |
+
`keyring`, e NUNCA para o config.json nem para o log. Sem o `keyring`, o e-mail fica desligado (não guarda senha em texto).
|
| 5 |
+
- Ler é só leitura: a caixa é aberta em modo somente leitura e os cabeçalhos vêm com PEEK, então nada vira "lido". "Meus e-mails de hoje"
|
| 6 |
+
lista quem escreveu e o assunto; "ler o último e-mail de X" mostra o começo do texto. O texto do e-mail é dado: nunca vira ordem.
|
| 7 |
+
- Enviar SEMPRE pergunta antes, com o destinatário, o assunto e o texto na tela; um destinatário por vez; sem lista nem cópia.
|
| 8 |
+
- Gmail e Outlook pessoal exigem senha de app (ou já não aceitam senha); contas com login por navegador (OAuth) não funcionam aqui.
|
| 9 |
+
Nada disso foi usado com um servidor de verdade (os testes usam servidores falsos).
|
| 10 |
+
"""
|
| 11 |
+
import datetime as dt
|
| 12 |
+
import email
|
| 13 |
+
import email.header
|
| 14 |
+
import email.message
|
| 15 |
+
import email.utils
|
| 16 |
+
import imaplib
|
| 17 |
+
import re
|
| 18 |
+
import smtplib
|
| 19 |
+
import ssl
|
| 20 |
+
import unicodedata
|
| 21 |
+
|
| 22 |
+
import tars_i18n
|
| 23 |
+
|
| 24 |
+
SERVICO = "openTARS-email"
|
| 25 |
+
LIMITE_LISTA = 12
|
| 26 |
+
LIMITE_TEXTO = 1200
|
| 27 |
+
LIMITE_BAIXAR = 200_000 # bytes do começo da mensagem (anexos grandes não vêm inteiros)
|
| 28 |
+
TEMPO_REDE = 15
|
| 29 |
+
MESES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] # o IMAP só entende estes
|
| 30 |
+
SERVIDORES = { # domínio -> (imap, smtp)
|
| 31 |
+
"gmail.com": ("imap.gmail.com", "smtp.gmail.com"), "googlemail.com": ("imap.gmail.com", "smtp.gmail.com"),
|
| 32 |
+
"outlook.com": ("outlook.office365.com", "smtp-mail.outlook.com"), "hotmail.com": ("outlook.office365.com", "smtp-mail.outlook.com"),
|
| 33 |
+
"live.com": ("outlook.office365.com", "smtp-mail.outlook.com"), "icloud.com": ("imap.mail.me.com", "smtp.mail.me.com"),
|
| 34 |
+
"me.com": ("imap.mail.me.com", "smtp.mail.me.com"), "yahoo.com": ("imap.mail.yahoo.com", "smtp.mail.yahoo.com"),
|
| 35 |
+
"yahoo.com.br": ("imap.mail.yahoo.com", "smtp.mail.yahoo.com"), "uol.com.br": ("imap.uol.com.br", "smtps.uol.com.br"),
|
| 36 |
+
"proton.me": ("127.0.0.1", "127.0.0.1"), # o Proton só fala IMAP pelo programa Bridge, que a pessoa instala e ajusta
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
TEXTOS = {
|
| 40 |
+
"pt": {"desligado": "O e-mail está desligado. Ligue em Configurações > Assistente.", "sem_senha": "Não achei a senha da conta no cofre do sistema. Digite de novo em Configurações > Assistente.",
|
| 41 |
+
"erro": "Não consegui falar com o servidor de e-mail ({erro}).", "nada_hoje": "Nenhum e-mail novo hoje.", "hoje": "Hoje chegaram {n} e-mails:",
|
| 42 |
+
"hoje_um": "Hoje chegou 1 e-mail:", "mais": "… e mais {n}.", "nao_achei": "Não achei e-mail de {quem}.", "de": "De {de}, {data}\nAssunto: {assunto}\n\n{texto}",
|
| 43 |
+
"sem_texto": "(sem texto)", "pedir": "Mandar este e-mail?\nPara: {para}\nAssunto: {assunto}\n\n{texto}", "negado": "Tudo bem, não enviei.",
|
| 44 |
+
"enviado": "E-mail enviado para {para}.", "sem_corpo": "Diga o que escrever: envia um e-mail para ana@exemplo.com dizendo que chego às 9h.",
|
| 45 |
+
"so_um": "Mando um e-mail por vez, para um destinatário.", "sem_assunto": "(sem assunto)"},
|
| 46 |
+
"en": {"desligado": "Email is off. Turn it on in Settings > Assistant.", "sem_senha": "I couldn't find the account password in the system keychain. Type it again in Settings > Assistant.",
|
| 47 |
+
"erro": "I couldn't reach the mail server ({erro}).", "nada_hoje": "No new email today.", "hoje": "{n} emails came in today:",
|
| 48 |
+
"hoje_um": "1 email came in today:", "mais": "… and {n} more.", "nao_achei": "I found no email from {quem}.", "de": "From {de}, {data}\nSubject: {assunto}\n\n{texto}",
|
| 49 |
+
"sem_texto": "(no text)", "pedir": "Send this email?\nTo: {para}\nSubject: {assunto}\n\n{texto}", "negado": "Okay, I didn't send it.",
|
| 50 |
+
"enviado": "Email sent to {para}.", "sem_corpo": "Tell me what to write: send an email to ana@example.com saying I arrive at 9.",
|
| 51 |
+
"so_um": "I send one email at a time, to one recipient.", "sem_assunto": "(no subject)"},
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _t(chave, idioma, **kw):
|
| 56 |
+
s = TEXTOS["pt" if idioma == "pt" else "en"][chave]
|
| 57 |
+
return s.format(**kw) if kw else s
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def normalizar(texto):
|
| 61 |
+
return unicodedata.normalize("NFKD", str(texto)).encode("ascii", "ignore").decode().lower()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------- conta e senha
|
| 65 |
+
class SemCofre(RuntimeError):
|
| 66 |
+
"""O pacote keyring não está instalado ou o sistema não tem cofre de senhas."""
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _cofre():
|
| 70 |
+
try:
|
| 71 |
+
import keyring
|
| 72 |
+
from keyring.backends import fail
|
| 73 |
+
except ImportError as e:
|
| 74 |
+
raise SemCofre("keyring não instalado") from e
|
| 75 |
+
if isinstance(keyring.get_keyring(), fail.Keyring):
|
| 76 |
+
raise SemCofre("este sistema não tem cofre de senhas")
|
| 77 |
+
return keyring
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def guardar_senha(usuario, senha):
|
| 81 |
+
_cofre().set_password(SERVICO, usuario, senha)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def ler_senha(usuario):
|
| 85 |
+
try:
|
| 86 |
+
return _cofre().get_password(SERVICO, usuario)
|
| 87 |
+
except SemCofre:
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def apagar_senha(usuario):
|
| 92 |
+
try:
|
| 93 |
+
_cofre().delete_password(SERVICO, usuario)
|
| 94 |
+
except Exception: # noqa: BLE001 (já não existia)
|
| 95 |
+
pass
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def conta():
|
| 99 |
+
"""{usuario, imap, imap_porta, smtp, smtp_porta} escolhida pela pessoa, ou None (desligado, o padrão)."""
|
| 100 |
+
c = tars_i18n.ler_config().get("email")
|
| 101 |
+
return c if isinstance(c, dict) and c.get("usuario") and c.get("imap") and c.get("smtp") else None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def servidores_do_dominio(endereco):
|
| 105 |
+
"""(imap, smtp) provável para o endereço, ou ('', '') se não conheço o provedor."""
|
| 106 |
+
return SERVIDORES.get(endereco.rsplit("@", 1)[-1].lower(), ("", ""))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def salvar_conta(usuario, senha, imap="", smtp="", imap_porta=993, smtp_porta=465):
|
| 110 |
+
"""Guarda a conta no config e a senha no cofre. Levanta SemCofre se não há cofre (aí nada é guardado). Vazio desliga."""
|
| 111 |
+
if not usuario:
|
| 112 |
+
antiga = conta()
|
| 113 |
+
tars_i18n.salvar_config(email={})
|
| 114 |
+
if antiga:
|
| 115 |
+
apagar_senha(antiga["usuario"])
|
| 116 |
+
return
|
| 117 |
+
padrao_imap, padrao_smtp = servidores_do_dominio(usuario)
|
| 118 |
+
if senha:
|
| 119 |
+
guardar_senha(usuario, senha) # primeiro o cofre: se falhar, a conta não fica pela metade
|
| 120 |
+
tars_i18n.salvar_config(email={"usuario": usuario, "imap": imap or padrao_imap, "smtp": smtp or padrao_smtp,
|
| 121 |
+
"imap_porta": int(imap_porta), "smtp_porta": int(smtp_porta)})
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ---------------------------------------------------------------- IMAP: ler
|
| 125 |
+
def _decodificar(valor):
|
| 126 |
+
try:
|
| 127 |
+
return str(email.header.make_header(email.header.decode_header(valor or ""))).strip()
|
| 128 |
+
except Exception: # noqa: BLE001 (cabeçalho torto: o texto cru serve)
|
| 129 |
+
return str(valor or "").strip()
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _abrir_imap(c, senha):
|
| 133 |
+
if not senha:
|
| 134 |
+
raise SemCofre("sem senha")
|
| 135 |
+
# sem ssl_context o imaplib usa ssl._create_stdlib_context(), que não confere o certificado (revisão de 13/11)
|
| 136 |
+
caixa = imaplib.IMAP4_SSL(c["imap"], int(c.get("imap_porta") or 993), timeout=TEMPO_REDE, ssl_context=ssl.create_default_context())
|
| 137 |
+
caixa.login(c["usuario"], senha)
|
| 138 |
+
caixa.select("INBOX", readonly=True) # só leitura: nada vira "lido", nada é apagado
|
| 139 |
+
return caixa
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _cabecalhos(caixa, numeros):
|
| 143 |
+
saida = []
|
| 144 |
+
for n in numeros:
|
| 145 |
+
tipo, dados = caixa.fetch(n, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])")
|
| 146 |
+
if tipo != "OK" or not dados or not isinstance(dados[0], tuple):
|
| 147 |
+
continue
|
| 148 |
+
msg = email.message_from_bytes(dados[0][1])
|
| 149 |
+
nome, endereco = email.utils.parseaddr(_decodificar(msg.get("From")))
|
| 150 |
+
saida.append({"de": nome or endereco, "endereco": endereco, "assunto": _decodificar(msg.get("Subject")), "data": msg.get("Date", "")})
|
| 151 |
+
return saida
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def de_hoje(agora=None, c=None, senha=None):
|
| 155 |
+
"""Os e-mails que chegaram hoje (do mais novo para o mais velho), só cabeçalhos."""
|
| 156 |
+
c, agora = c or conta(), agora or dt.datetime.now()
|
| 157 |
+
caixa = _abrir_imap(c, senha or ler_senha(c["usuario"]))
|
| 158 |
+
try:
|
| 159 |
+
desde = f"{agora.day:02d}-{MESES[agora.month - 1]}-{agora.year}"
|
| 160 |
+
tipo, dados = caixa.search(None, "SINCE", desde)
|
| 161 |
+
numeros = (dados[0].split() if tipo == "OK" and dados and dados[0] else [])[::-1]
|
| 162 |
+
return {"total": len(numeros), "itens": _cabecalhos(caixa, numeros[:LIMITE_LISTA])}
|
| 163 |
+
finally:
|
| 164 |
+
try:
|
| 165 |
+
caixa.logout()
|
| 166 |
+
except Exception: # noqa: BLE001
|
| 167 |
+
pass
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _texto_da_mensagem(msg):
|
| 171 |
+
for parte in msg.walk() if msg.is_multipart() else [msg]:
|
| 172 |
+
if parte.get_content_type() == "text/plain" and not parte.get_filename():
|
| 173 |
+
carga = parte.get_payload(decode=True) or b""
|
| 174 |
+
return carga.decode(parte.get_content_charset() or "utf-8", "replace").strip()
|
| 175 |
+
for parte in msg.walk() if msg.is_multipart() else [msg]:
|
| 176 |
+
if parte.get_content_type() == "text/html":
|
| 177 |
+
carga = (parte.get_payload(decode=True) or b"").decode(parte.get_content_charset() or "utf-8", "replace")
|
| 178 |
+
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", re.sub(r"(?is)<(script|style).*?</\1>", " ", carga))).strip()
|
| 179 |
+
return ""
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def ultimo_de(quem, c=None, senha=None):
|
| 183 |
+
"""O e-mail mais recente de alguém (nome ou endereço): {de, data, assunto, texto} ou None."""
|
| 184 |
+
c = c or conta()
|
| 185 |
+
quem = re.sub(r'["\\\r\n]', "", quem).strip()
|
| 186 |
+
if not quem:
|
| 187 |
+
return None
|
| 188 |
+
caixa = _abrir_imap(c, senha or ler_senha(c["usuario"]))
|
| 189 |
+
try:
|
| 190 |
+
tipo, dados = caixa.search(None, "FROM", f'"{quem}"')
|
| 191 |
+
numeros = dados[0].split() if tipo == "OK" and dados and dados[0] else []
|
| 192 |
+
if not numeros:
|
| 193 |
+
return None
|
| 194 |
+
tipo, dados = caixa.fetch(numeros[-1], f"(BODY.PEEK[]<0.{LIMITE_BAIXAR}>)")
|
| 195 |
+
if tipo != "OK" or not dados or not isinstance(dados[0], tuple):
|
| 196 |
+
return None
|
| 197 |
+
msg = email.message_from_bytes(dados[0][1])
|
| 198 |
+
nome, endereco = email.utils.parseaddr(_decodificar(msg.get("From")))
|
| 199 |
+
return {"de": nome or endereco, "data": msg.get("Date", ""), "assunto": _decodificar(msg.get("Subject")),
|
| 200 |
+
"texto": _texto_da_mensagem(msg)[:LIMITE_TEXTO]}
|
| 201 |
+
finally:
|
| 202 |
+
try:
|
| 203 |
+
caixa.logout()
|
| 204 |
+
except Exception: # noqa: BLE001
|
| 205 |
+
pass
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ---------------------------------------------------------------- SMTP: enviar
|
| 209 |
+
EMAIL_VALIDO = re.compile(r"^[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+$")
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def enviar(para, assunto, texto, c=None, senha=None):
|
| 213 |
+
"""Manda UM e-mail. Quem chama já pediu a aprovação da pessoa."""
|
| 214 |
+
c = c or conta()
|
| 215 |
+
if not EMAIL_VALIDO.match(para):
|
| 216 |
+
raise ValueError("destinatário inválido")
|
| 217 |
+
if not (senha or ler_senha(c["usuario"])):
|
| 218 |
+
raise SemCofre("sem senha")
|
| 219 |
+
msg = email.message.EmailMessage()
|
| 220 |
+
msg["From"], msg["To"], msg["Subject"] = c["usuario"], para, assunto
|
| 221 |
+
msg["Date"] = email.utils.formatdate(localtime=True)
|
| 222 |
+
msg.set_content(texto)
|
| 223 |
+
porta, contexto = int(c.get("smtp_porta") or 465), ssl.create_default_context()
|
| 224 |
+
senha = senha or ler_senha(c["usuario"])
|
| 225 |
+
if porta == 465:
|
| 226 |
+
with smtplib.SMTP_SSL(c["smtp"], porta, timeout=TEMPO_REDE, context=contexto) as s:
|
| 227 |
+
s.login(c["usuario"], senha)
|
| 228 |
+
s.send_message(msg)
|
| 229 |
+
else:
|
| 230 |
+
with smtplib.SMTP(c["smtp"], porta, timeout=TEMPO_REDE) as s:
|
| 231 |
+
s.starttls(context=contexto)
|
| 232 |
+
s.login(c["usuario"], senha)
|
| 233 |
+
s.send_message(msg)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def testar(c=None, senha=None):
|
| 237 |
+
"""Entra na caixa e sai (só leitura). Devolve None se deu certo, ou o motivo."""
|
| 238 |
+
c = c or conta()
|
| 239 |
+
try:
|
| 240 |
+
caixa = _abrir_imap(c, senha or ler_senha(c["usuario"]))
|
| 241 |
+
caixa.logout()
|
| 242 |
+
return None
|
| 243 |
+
except (imaplib.IMAP4.error, OSError, ssl.SSLError) as e:
|
| 244 |
+
return str(e)[:100] or type(e).__name__
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ---------------------------------------------------------------- frases
|
| 248 |
+
_NOVOS = re.compile(r"\b(meus|my|novos?|new|chegou|chegaram|tenho|do i have|check|ver|mostra|mostre|show|quais|que|what|resume|resumo|summar\w+)\b.*"
|
| 249 |
+
r"\be-?mails?\b.*\b(hoje|today)\b|\b(tenho|do i have|any)\b.*\b(novos?|new)\b.*\be-?mails?\b|\b(novos?|new) e-?mails?\b")
|
| 250 |
+
_LER = re.compile(r"\b(ler|le|leia|abre|abra|mostra|mostre|read|show|open)\b.*\b(ultimo|ultima|last|latest|most recent)\b.*\be-?mail\b.*"
|
| 251 |
+
r"\b(de|da|do|from)\s+(?P<quem>.+)$")
|
| 252 |
+
_ENVIAR = re.compile(r"^\s*(?:por favor,?\s+|please,?\s+)?(?:envia|envie|enviar|manda|mande|mandar|escreve|escreva|send|write|email)\b.*?\be-?mails?\b.*?"
|
| 253 |
+
r"\b(?:para|pra|to)\s+(?P<para>[^\s,;<>]+@[^\s,;<>]+?)[.,;]?(?P<resto>\s.*)?$", re.IGNORECASE | re.DOTALL)
|
| 254 |
+
_CORPO = re.compile(r"\b(?:dizendo|falando|avisando|com o texto|com a mensagem|saying|telling (?:him|her|them)|with the message|that says)\b[:\s]*(?P<c>.+)$", re.IGNORECASE | re.DOTALL)
|
| 255 |
+
_ASSUNTO = re.compile(r"\b(?:assunto|subject)\b[:\s]+(?P<a>.+?)(?=\s+(?:dizendo|falando|avisando|com o texto|com a mensagem|saying|with the message|that says)\b|$)", re.IGNORECASE | re.DOTALL)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _data_curta(texto):
|
| 259 |
+
try:
|
| 260 |
+
return email.utils.parsedate_to_datetime(texto).astimezone().strftime("%d/%m %H:%M")
|
| 261 |
+
except (TypeError, ValueError):
|
| 262 |
+
return texto[:16]
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def responder(pedido, idioma, core=None):
|
| 266 |
+
"""Lista os e-mails de hoje, lê o último de alguém ou manda um e-mail (com aprovação). None se a frase não é de e-mail.
|
| 267 |
+
Desligado (sem conta) também devolve None: o pedido segue para o modelo, como antes."""
|
| 268 |
+
n = normalizar(pedido).replace("e-mail", "email")
|
| 269 |
+
c = conta()
|
| 270 |
+
if c is None:
|
| 271 |
+
return None
|
| 272 |
+
m_env = _ENVIAR.match(pedido)
|
| 273 |
+
if m_env:
|
| 274 |
+
return _enviar_por_frase(m_env, idioma, core, c)
|
| 275 |
+
try:
|
| 276 |
+
m = _LER.search(n)
|
| 277 |
+
if m:
|
| 278 |
+
quem = pedido[len(pedido) - len(m.group("quem")):].strip(" ?.!") # com as maiúsculas e acentos de quem escreveu
|
| 279 |
+
achado = ultimo_de(quem, c)
|
| 280 |
+
if not achado:
|
| 281 |
+
return _t("nao_achei", idioma, quem=quem)
|
| 282 |
+
return _t("de", idioma, de=achado["de"], data=_data_curta(achado["data"]), assunto=achado["assunto"] or _t("sem_assunto", idioma),
|
| 283 |
+
texto=achado["texto"] or _t("sem_texto", idioma))
|
| 284 |
+
if _NOVOS.search(n):
|
| 285 |
+
hoje = de_hoje(c=c)
|
| 286 |
+
if not hoje["itens"]:
|
| 287 |
+
return _t("nada_hoje", idioma)
|
| 288 |
+
linhas = [_t("hoje_um" if hoje["total"] == 1 else "hoje", idioma, n=hoje["total"])]
|
| 289 |
+
linhas += [f"• {i['de']}: {i['assunto'] or _t('sem_assunto', idioma)}" for i in hoje["itens"]]
|
| 290 |
+
if hoje["total"] > len(hoje["itens"]):
|
| 291 |
+
linhas.append(_t("mais", idioma, n=hoje["total"] - len(hoje["itens"])))
|
| 292 |
+
return "\n".join(linhas)
|
| 293 |
+
except SemCofre:
|
| 294 |
+
return _t("sem_senha", idioma)
|
| 295 |
+
except (imaplib.IMAP4.error, OSError, ssl.SSLError, smtplib.SMTPException) as e:
|
| 296 |
+
return _t("erro", idioma, erro=str(e)[:80] or type(e).__name__)
|
| 297 |
+
return None
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _enviar_por_frase(m, idioma, core, c):
|
| 301 |
+
resto = m.group("resto") or ""
|
| 302 |
+
corpo = _CORPO.search(resto)
|
| 303 |
+
if "@" in (resto[:corpo.start()] if corpo else ""): # "para a@x.com e b@y.com dizendo...": um destinatário por vez
|
| 304 |
+
return _t("so_um", idioma)
|
| 305 |
+
texto = re.sub(r"^(?:que|that)\s+", "", (corpo.group("c") if corpo else resto.strip(" :,-")).strip(), flags=re.IGNORECASE)
|
| 306 |
+
if not texto:
|
| 307 |
+
return _t("sem_corpo", idioma)
|
| 308 |
+
texto = texto[:1].upper() + texto[1:]
|
| 309 |
+
assunto = _ASSUNTO.search(resto)
|
| 310 |
+
assunto = (assunto.group("a").strip(" .,:") if assunto else "") or _t("sem_assunto", idioma)
|
| 311 |
+
para = m.group("para").strip("<>")
|
| 312 |
+
if not EMAIL_VALIDO.match(para):
|
| 313 |
+
return _t("sem_corpo", idioma)
|
| 314 |
+
# nunca sai sem a pessoa ver e dizer sim: sem janela para perguntar, ou com as perguntas desligadas (TARS_SEM_CONFIRMACAO_PERIGOSOS), não envia
|
| 315 |
+
if core is None or not getattr(core, "_CONFIRMAR_COMANDOS_PERIGOSOS", False) \
|
| 316 |
+
or not core._confirmar_comando_perigoso(_t("pedir", idioma, para=para, assunto=assunto, texto=texto[:500])):
|
| 317 |
+
return _t("negado", idioma)
|
| 318 |
+
try:
|
| 319 |
+
enviar(para, assunto, texto, c)
|
| 320 |
+
except SemCofre:
|
| 321 |
+
return _t("sem_senha", idioma)
|
| 322 |
+
except (imaplib.IMAP4.error, OSError, ssl.SSLError, smtplib.SMTPException, ValueError) as e:
|
| 323 |
+
return _t("erro", idioma, erro=str(e)[:80] or type(e).__name__)
|
| 324 |
+
return _t("enviado", idioma, para=para)
|
app/tars_gerenciar_modelos.py
CHANGED
|
@@ -11,6 +11,7 @@ import urllib.error
|
|
| 11 |
import urllib.request
|
| 12 |
|
| 13 |
import tars_primeiro_uso as pu
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
def _tamanho_gb(m):
|
|
@@ -20,7 +21,7 @@ def _tamanho_gb(m):
|
|
| 20 |
def listar(host, abrir=None):
|
| 21 |
"""Os modelos instalados, do maior para o menor: [{nome, gb, parametros, quantizacao, familia, data, nuvem, oficial}].
|
| 22 |
Levanta OSError se o Ollama não responder."""
|
| 23 |
-
pedido = urllib.request.Request(
|
| 24 |
with (abrir or urllib.request.urlopen)(pedido, timeout=6) as r:
|
| 25 |
dados = json.loads(r.read())
|
| 26 |
saida = []
|
|
@@ -50,7 +51,7 @@ def apagar(nome, host, abrir=None):
|
|
| 50 |
"""Apaga `nome` do Ollama (DELETE /api/delete). Devolve (ok, erro). Os modelos que outros modelos compartilham
|
| 51 |
continuam: o Ollama só remove as camadas que mais ninguém usa."""
|
| 52 |
corpo = json.dumps({"model": nome, "name": nome}).encode() # "name" para os Ollama mais antigos
|
| 53 |
-
pedido = urllib.request.Request(
|
| 54 |
headers={"Content-Type": "application/json"})
|
| 55 |
try:
|
| 56 |
with (abrir or urllib.request.urlopen)(pedido, timeout=30):
|
|
@@ -72,7 +73,7 @@ REGISTRO = "https://registry.ollama.ai/v2/{repositorio}/manifests/{tag}"
|
|
| 72 |
|
| 73 |
def camada_local(nome, host, abrir=None):
|
| 74 |
"""O digest da camada de pesos do modelo instalado, ou "" se não deu para saber."""
|
| 75 |
-
pedido = urllib.request.Request(
|
| 76 |
headers={"Content-Type": "application/json"})
|
| 77 |
with (abrir or urllib.request.urlopen)(pedido, timeout=10) as r:
|
| 78 |
achados = re.findall(r"sha256[-:]([0-9a-f]{64})", json.loads(r.read()).get("modelfile") or "")
|
|
|
|
| 11 |
import urllib.request
|
| 12 |
|
| 13 |
import tars_primeiro_uso as pu
|
| 14 |
+
import tars_motor as motor
|
| 15 |
|
| 16 |
|
| 17 |
def _tamanho_gb(m):
|
|
|
|
| 21 |
def listar(host, abrir=None):
|
| 22 |
"""Os modelos instalados, do maior para o menor: [{nome, gb, parametros, quantizacao, familia, data, nuvem, oficial}].
|
| 23 |
Levanta OSError se o Ollama não responder."""
|
| 24 |
+
pedido = urllib.request.Request(motor.url("modelos", host))
|
| 25 |
with (abrir or urllib.request.urlopen)(pedido, timeout=6) as r:
|
| 26 |
dados = json.loads(r.read())
|
| 27 |
saida = []
|
|
|
|
| 51 |
"""Apaga `nome` do Ollama (DELETE /api/delete). Devolve (ok, erro). Os modelos que outros modelos compartilham
|
| 52 |
continuam: o Ollama só remove as camadas que mais ninguém usa."""
|
| 53 |
corpo = json.dumps({"model": nome, "name": nome}).encode() # "name" para os Ollama mais antigos
|
| 54 |
+
pedido = urllib.request.Request(motor.url("apagar", host), data=corpo, method="DELETE",
|
| 55 |
headers={"Content-Type": "application/json"})
|
| 56 |
try:
|
| 57 |
with (abrir or urllib.request.urlopen)(pedido, timeout=30):
|
|
|
|
| 73 |
|
| 74 |
def camada_local(nome, host, abrir=None):
|
| 75 |
"""O digest da camada de pesos do modelo instalado, ou "" se não deu para saber."""
|
| 76 |
+
pedido = urllib.request.Request(motor.url("detalhes", host), data=json.dumps({"model": nome}).encode(),
|
| 77 |
headers={"Content-Type": "application/json"})
|
| 78 |
with (abrir or urllib.request.urlopen)(pedido, timeout=10) as r:
|
| 79 |
achados = re.findall(r"sha256[-:]([0-9a-f]{64})", json.loads(r.read()).get("modelfile") or "")
|
app/tars_gui.py
CHANGED
|
@@ -1299,30 +1299,10 @@ class JanelaTars(tk.Tk):
|
|
| 1299 |
self._trabalho = threading.Thread(target=alvo, daemon=True)
|
| 1300 |
self._trabalho.start()
|
| 1301 |
|
| 1302 |
-
def _na_ui(self, funcao):
|
| 1303 |
-
"""Chamado de outra thread: roda na thread da janela."""
|
| 1304 |
-
self._fila_saida.put(_NaInterface(funcao))
|
| 1305 |
-
|
| 1306 |
# ------------------------------------------------------------
|
| 1307 |
# Escolha da IA (um seletor só: Automático ou um modelo)
|
| 1308 |
# ------------------------------------------------------------
|
| 1309 |
|
| 1310 |
-
def _recontar_modelos(self):
|
| 1311 |
-
"""Conta de novo os modelos da barra de status."""
|
| 1312 |
-
def alvo():
|
| 1313 |
-
try:
|
| 1314 |
-
catalogo = core.catalogar_modelos(forcar=True)
|
| 1315 |
-
n = sum(1 for m in catalogo.values()
|
| 1316 |
-
if m["categoria"] != "embedding" and m["tag"] not in core.MODELOS_AJUDANTES)
|
| 1317 |
-
except Exception:
|
| 1318 |
-
return
|
| 1319 |
-
|
| 1320 |
-
def aplicar():
|
| 1321 |
-
self._info["n_modelos"] = n
|
| 1322 |
-
self._atualizar_status(self._info)
|
| 1323 |
-
self._fila_saida.put(_NaInterface(aplicar))
|
| 1324 |
-
threading.Thread(target=alvo, daemon=True).start()
|
| 1325 |
-
|
| 1326 |
def _modelos_escolhiveis(self):
|
| 1327 |
# Esta função roda na thread da janela: com a lista já guardada, usa ela (e atualiza em segundo
|
| 1328 |
# plano), em vez de esperar até 5 s pelo Ollama a cada resposta e travar a tela.
|
|
|
|
| 1299 |
self._trabalho = threading.Thread(target=alvo, daemon=True)
|
| 1300 |
self._trabalho.start()
|
| 1301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1302 |
# ------------------------------------------------------------
|
| 1303 |
# Escolha da IA (um seletor só: Automático ou um modelo)
|
| 1304 |
# ------------------------------------------------------------
|
| 1305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1306 |
def _modelos_escolhiveis(self):
|
| 1307 |
# Esta função roda na thread da janela: com a lista já guardada, usa ela (e atualiza em segundo
|
| 1308 |
# plano), em vez de esperar até 5 s pelo Ollama a cada resposta e travar a tela.
|
app/tars_imagem.py
CHANGED
|
@@ -23,6 +23,7 @@ import time
|
|
| 23 |
from pathlib import Path
|
| 24 |
|
| 25 |
import tars_plataforma as plataforma
|
|
|
|
| 26 |
|
| 27 |
REPOSITORIO = "https://github.com/PrismML-Eng/Bonsai-Image-Demo.git"
|
| 28 |
COMMIT = "de1e5c0323e7a0474c68c6cdd075bad4e782fb0e"
|
|
@@ -123,10 +124,10 @@ def descarregar_ollama(host, abrir=None):
|
|
| 123 |
import urllib.request
|
| 124 |
abrir = abrir or urllib.request.urlopen
|
| 125 |
try:
|
| 126 |
-
with abrir(
|
| 127 |
nomes = [m.get("name") for m in json.loads(r.read()).get("models", [])]
|
| 128 |
for nome in nomes:
|
| 129 |
-
pedido = urllib.request.Request(
|
| 130 |
headers={"Content-Type": "application/json"})
|
| 131 |
with abrir(pedido, timeout=30):
|
| 132 |
pass
|
|
|
|
| 23 |
from pathlib import Path
|
| 24 |
|
| 25 |
import tars_plataforma as plataforma
|
| 26 |
+
import tars_motor as motor
|
| 27 |
|
| 28 |
REPOSITORIO = "https://github.com/PrismML-Eng/Bonsai-Image-Demo.git"
|
| 29 |
COMMIT = "de1e5c0323e7a0474c68c6cdd075bad4e782fb0e"
|
|
|
|
| 124 |
import urllib.request
|
| 125 |
abrir = abrir or urllib.request.urlopen
|
| 126 |
try:
|
| 127 |
+
with abrir(motor.url("rodando", host), timeout=5) as r:
|
| 128 |
nomes = [m.get("name") for m in json.loads(r.read()).get("models", [])]
|
| 129 |
for nome in nomes:
|
| 130 |
+
pedido = urllib.request.Request(motor.url("gerar", host), data=json.dumps({"model": nome, "keep_alive": 0}).encode(),
|
| 131 |
headers={"Content-Type": "application/json"})
|
| 132 |
with abrir(pedido, timeout=30):
|
| 133 |
pass
|
app/tars_integracoes.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.3 (F4+++): integrações do assistente com a agenda e com as notas da pessoa, resolvidas por código (sem o modelo) e
|
| 2 |
+
DESLIGADAS por padrão: só valem depois de a pessoa dizer, nas Configurações > Assistente, qual arquivo .ics (ou link) e qual
|
| 3 |
+
pasta de notas usar. Só biblioteca padrão (mais o `requests` do app para o link do calendário). Nada sai do computador: o
|
| 4 |
+
calendário é só lido, e as notas são arquivos Markdown comuns numa pasta que a pessoa escolheu.
|
| 5 |
+
|
| 6 |
+
Agenda: lê eventos de arquivos .ics (Google, Outlook, Apple e Thunderbird exportam) com repetição diária, semanal, mensal e anual
|
| 7 |
+
(INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, exceções e instâncias alteradas). Responde "o que tenho amanhã?", "what's on my
|
| 8 |
+
calendar this week?", "qual meu próximo compromisso?".
|
| 9 |
+
Limites: sem enviar convite nem criar evento (só leitura); fuso com nome do Windows ("W. Europe Standard Time") que o sistema
|
| 10 |
+
não conhece é tratado como horário local; BYSETPOS e regras raras ficam de fora e o evento só vale na primeira data.
|
| 11 |
+
Notas: "cria uma nota: ..." grava um .md novo (nunca por cima de outro) e "minhas notas sobre X" / "show the note X" procura e lê.
|
| 12 |
+
"""
|
| 13 |
+
import datetime as dt
|
| 14 |
+
import re
|
| 15 |
+
import time
|
| 16 |
+
import unicodedata
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import tars_i18n
|
| 20 |
+
|
| 21 |
+
CACHE_LINK_S = 900
|
| 22 |
+
LIMITE_ITERACOES = 6000
|
| 23 |
+
LIMITE_LISTA = 15
|
| 24 |
+
_cache_link = {}
|
| 25 |
+
|
| 26 |
+
TEXTOS = {
|
| 27 |
+
"pt": {"nada": "Não há nada na agenda para {quando}.", "um": "{Quando} você tem 1 compromisso:", "varios": "{Quando} você tem {n} compromissos:",
|
| 28 |
+
"dia_inteiro": "dia inteiro", "proximo": "Seu próximo compromisso: {quando}, {titulo}{local}.", "sem_proximo": "Não achei compromisso nos próximos 60 dias.",
|
| 29 |
+
"mais": "… e mais {n}.", "erro": "Não consegui ler a agenda ({erro}).",
|
| 30 |
+
"nota_criada": "Nota criada: {nome}, em {pasta}.", "nota_vazia": "Diga o que escrever: cria uma nota: comprar pão.",
|
| 31 |
+
"notas_nada": "Não achei nada nas notas sobre {termo}.", "notas_achei": "Achei {n} em suas notas:", "nota_texto": "{nome}:\n{texto}",
|
| 32 |
+
"nota_nao_achei": "Não achei a nota {termo}.",
|
| 33 |
+
"hoje": "hoje", "amanha": "amanhã", "depois": "depois de amanhã", "semana": "esta semana", "semana_que_vem": "a semana que vem",
|
| 34 |
+
"dias": ["segunda", "terça", "quarta", "quinta", "sexta", "sábado", "domingo"]},
|
| 35 |
+
"en": {"nada": "There is nothing on your calendar for {quando}.", "um": "{Quando} you have 1 event:", "varios": "{Quando} you have {n} events:",
|
| 36 |
+
"dia_inteiro": "all day", "proximo": "Your next event: {quando}, {titulo}{local}.", "sem_proximo": "I found no events in the next 60 days.",
|
| 37 |
+
"mais": "… and {n} more.", "erro": "I couldn't read the calendar ({erro}).",
|
| 38 |
+
"nota_criada": "Note created: {nome}, in {pasta}.", "nota_vazia": "Tell me what to write: create a note: buy bread.",
|
| 39 |
+
"notas_nada": "I found nothing about {termo} in your notes.", "notas_achei": "Found {n} in your notes:", "nota_texto": "{nome}:\n{texto}",
|
| 40 |
+
"nota_nao_achei": "I couldn't find the note {termo}.",
|
| 41 |
+
"hoje": "today", "amanha": "tomorrow", "depois": "the day after tomorrow", "semana": "this week", "semana_que_vem": "next week",
|
| 42 |
+
"dias": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]},
|
| 43 |
+
}
|
| 44 |
+
_DIAS_ICS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _t(chave, idioma, **kw):
|
| 48 |
+
return TEXTOS["pt" if idioma == "pt" else "en"][chave].format(**kw) if kw else TEXTOS["pt" if idioma == "pt" else "en"][chave]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def normalizar(texto):
|
| 52 |
+
return unicodedata.normalize("NFKD", str(texto)).encode("ascii", "ignore").decode().lower()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------- configuração (desligado por padrão)
|
| 56 |
+
def agendas():
|
| 57 |
+
"""Os arquivos .ics ou links escolhidos pela pessoa; lista vazia = agenda desligada."""
|
| 58 |
+
valor = tars_i18n.ler_config().get("agenda_ics") or []
|
| 59 |
+
return [str(v).strip() for v in ([valor] if isinstance(valor, str) else valor) if str(v).strip()]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def pasta_de_notas():
|
| 63 |
+
valor = str(tars_i18n.ler_config().get("notas_pasta") or "").strip()
|
| 64 |
+
return Path(valor).expanduser() if valor else None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ---------------------------------------------------------------- ICS: ler
|
| 68 |
+
def _desdobrar(texto):
|
| 69 |
+
"""Linhas do iCalendar: uma linha que começa com espaço ou tab continua a anterior."""
|
| 70 |
+
linhas = []
|
| 71 |
+
for bruta in texto.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
| 72 |
+
if bruta[:1] in (" ", "\t") and linhas:
|
| 73 |
+
linhas[-1] += bruta[1:]
|
| 74 |
+
elif bruta:
|
| 75 |
+
linhas.append(bruta)
|
| 76 |
+
return linhas
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _propriedade(linha):
|
| 80 |
+
cabeca, _, valor = linha.partition(":")
|
| 81 |
+
nome, *params = cabeca.split(";")
|
| 82 |
+
p = {}
|
| 83 |
+
for par in params:
|
| 84 |
+
k, _, v = par.partition("=")
|
| 85 |
+
p[k.upper()] = v.strip('"')
|
| 86 |
+
return nome.upper(), p, valor
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _desescapar(valor):
|
| 90 |
+
return valor.replace("\\n", " ").replace("\\N", " ").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _fuso(nome):
|
| 94 |
+
if not nome:
|
| 95 |
+
return None
|
| 96 |
+
try:
|
| 97 |
+
from zoneinfo import ZoneInfo
|
| 98 |
+
return ZoneInfo(nome)
|
| 99 |
+
except Exception: # noqa: BLE001 (nome do Windows ou sem tzdata: horário local)
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _quando(valor, params):
|
| 104 |
+
"""(data e hora SEM fuso no horário do próprio evento, fuso, dia_inteiro)."""
|
| 105 |
+
valor = valor.strip()
|
| 106 |
+
if params.get("VALUE") == "DATE" or (len(valor) == 8 and valor.isdigit()):
|
| 107 |
+
return dt.datetime.strptime(valor[:8], "%Y%m%d"), None, True
|
| 108 |
+
utc = valor.endswith("Z")
|
| 109 |
+
naive = dt.datetime.strptime(valor.rstrip("Z")[:15], "%Y%m%dT%H%M%S")
|
| 110 |
+
if utc:
|
| 111 |
+
return naive, dt.timezone.utc, False
|
| 112 |
+
return naive, _fuso(params.get("TZID")), False
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _local(naive, fuso):
|
| 116 |
+
"""O horário da parede do evento, no horário local do computador, sem fuso."""
|
| 117 |
+
if fuso is None:
|
| 118 |
+
return naive
|
| 119 |
+
return naive.replace(tzinfo=fuso).astimezone().replace(tzinfo=None)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _duracao(valor):
|
| 123 |
+
m = re.fullmatch(r"([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?", valor.strip())
|
| 124 |
+
if not m:
|
| 125 |
+
return dt.timedelta(0)
|
| 126 |
+
sinal = -1 if m.group(1) == "-" else 1
|
| 127 |
+
sem, dia, h, mi, s = (int(x or 0) for x in m.groups()[1:])
|
| 128 |
+
return sinal * dt.timedelta(weeks=sem, days=dia, hours=h, minutes=mi, seconds=s)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def ler_ics(texto):
|
| 132 |
+
"""Os eventos do texto de um .ics: [{uid, inicio, fuso, dia_inteiro, duracao, titulo, local, regra, excecoes, id_instancia}]."""
|
| 133 |
+
eventos, atual = [], None
|
| 134 |
+
for linha in _desdobrar(texto):
|
| 135 |
+
if linha.upper() == "BEGIN:VEVENT":
|
| 136 |
+
atual = {"props": []}
|
| 137 |
+
elif linha.upper() == "END:VEVENT" and atual is not None:
|
| 138 |
+
e = _montar(atual["props"])
|
| 139 |
+
if e:
|
| 140 |
+
eventos.append(e)
|
| 141 |
+
atual = None
|
| 142 |
+
elif atual is not None:
|
| 143 |
+
atual["props"].append(_propriedade(linha))
|
| 144 |
+
return eventos
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _montar(props):
|
| 148 |
+
d = {}
|
| 149 |
+
excecoes = []
|
| 150 |
+
for nome, p, valor in props:
|
| 151 |
+
if nome == "EXDATE":
|
| 152 |
+
excecoes += [(v, p) for v in valor.split(",") if v.strip()]
|
| 153 |
+
else:
|
| 154 |
+
d.setdefault(nome, (p, valor))
|
| 155 |
+
if "DTSTART" not in d or d.get("STATUS", ({}, ""))[1].upper() == "CANCELLED":
|
| 156 |
+
return None
|
| 157 |
+
try:
|
| 158 |
+
inicio, fuso, inteiro = _quando(d["DTSTART"][1], d["DTSTART"][0])
|
| 159 |
+
if "DTEND" in d:
|
| 160 |
+
fim, fuso_fim, _ = _quando(d["DTEND"][1], d["DTEND"][0])
|
| 161 |
+
duracao = (fim - inicio) if fuso_fim == fuso else _local(fim, fuso_fim) - _local(inicio, fuso)
|
| 162 |
+
elif "DURATION" in d:
|
| 163 |
+
duracao = _duracao(d["DURATION"][1])
|
| 164 |
+
else:
|
| 165 |
+
duracao = dt.timedelta(days=1) if inteiro else dt.timedelta(0)
|
| 166 |
+
regra = {}
|
| 167 |
+
for parte in (d.get("RRULE", ({}, ""))[1]).split(";"):
|
| 168 |
+
k, _, v = parte.partition("=")
|
| 169 |
+
if k:
|
| 170 |
+
regra[k.upper()] = v
|
| 171 |
+
sem = {_quando(v, p)[0] for v, p in excecoes}
|
| 172 |
+
instancia = _quando(d["RECURRENCE-ID"][1], d["RECURRENCE-ID"][0])[0] if "RECURRENCE-ID" in d else None
|
| 173 |
+
except (ValueError, KeyError):
|
| 174 |
+
return None
|
| 175 |
+
return {"uid": d.get("UID", ({}, ""))[1], "inicio": inicio, "fuso": fuso, "dia_inteiro": inteiro, "duracao": duracao,
|
| 176 |
+
"titulo": _desescapar(d.get("SUMMARY", ({}, ""))[1]) or "(sem título)", "local": _desescapar(d.get("LOCATION", ({}, ""))[1]),
|
| 177 |
+
"regra": regra, "excecoes": sem, "id_instancia": instancia}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ---------------------------------------------------------------- ICS: repetir
|
| 181 |
+
def _passos_da_regra(e):
|
| 182 |
+
"""As datas (horário da parede do evento) de cada ocorrência, em ordem, a partir do início. Para quando a regra acaba."""
|
| 183 |
+
r, inicio = e["regra"], e["inicio"]
|
| 184 |
+
if not r.get("FREQ"):
|
| 185 |
+
yield inicio
|
| 186 |
+
return
|
| 187 |
+
freq, intervalo = r["FREQ"].upper(), max(1, int(r.get("INTERVAL") or 1))
|
| 188 |
+
contagem = int(r["COUNT"]) if r.get("COUNT", "").isdigit() else None
|
| 189 |
+
ate = None
|
| 190 |
+
if r.get("UNTIL"):
|
| 191 |
+
try:
|
| 192 |
+
u, fuso_u, _ = _quando(r["UNTIL"], {})
|
| 193 |
+
ate = u if fuso_u is None else _local(u, fuso_u)
|
| 194 |
+
ate = ate + dt.timedelta(days=1) if len(r["UNTIL"]) == 8 else ate
|
| 195 |
+
except ValueError:
|
| 196 |
+
ate = None
|
| 197 |
+
emitidos = 0
|
| 198 |
+
hora = inicio.time()
|
| 199 |
+
por_dia = [(int(m.group(1) or 0), _DIAS_ICS[m.group(2)]) for m in re.finditer(r"([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)", r.get("BYDAY", ""))]
|
| 200 |
+
dias_mes = [int(x) for x in r.get("BYMONTHDAY", "").split(",") if x.strip("-").isdigit()]
|
| 201 |
+
|
| 202 |
+
def candidatos():
|
| 203 |
+
if freq == "DAILY":
|
| 204 |
+
n = 0
|
| 205 |
+
while True:
|
| 206 |
+
yield inicio + dt.timedelta(days=n * intervalo)
|
| 207 |
+
n += 1
|
| 208 |
+
elif freq == "WEEKLY":
|
| 209 |
+
segunda = inicio - dt.timedelta(days=inicio.weekday())
|
| 210 |
+
dias = sorted({d for _, d in por_dia}) or [inicio.weekday()]
|
| 211 |
+
n = 0
|
| 212 |
+
while True:
|
| 213 |
+
base = segunda + dt.timedelta(weeks=n * intervalo)
|
| 214 |
+
for d in dias:
|
| 215 |
+
yield dt.datetime.combine((base + dt.timedelta(days=d)).date(), hora)
|
| 216 |
+
n += 1
|
| 217 |
+
elif freq in ("MONTHLY", "YEARLY"):
|
| 218 |
+
n = 0
|
| 219 |
+
while True:
|
| 220 |
+
total = (inicio.year * 12 + inicio.month - 1) + (n * intervalo * (12 if freq == "YEARLY" else 1))
|
| 221 |
+
ano, mes = divmod(total, 12)
|
| 222 |
+
mes += 1
|
| 223 |
+
if freq == "YEARLY" and por_dia == [] and not dias_mes:
|
| 224 |
+
dias = [inicio.day]
|
| 225 |
+
else:
|
| 226 |
+
dias = list(dias_mes) or []
|
| 227 |
+
if por_dia and freq == "MONTHLY":
|
| 228 |
+
dias += _dias_da_semana_do_mes(ano, mes, por_dia)
|
| 229 |
+
if not dias:
|
| 230 |
+
dias = [inicio.day]
|
| 231 |
+
ultimo = (dt.date(ano + (mes == 12), mes % 12 + 1, 1) - dt.timedelta(days=1)).day
|
| 232 |
+
for d in sorted(set(dias)):
|
| 233 |
+
d = ultimo + 1 + d if d < 0 else d
|
| 234 |
+
if 1 <= d <= ultimo:
|
| 235 |
+
yield dt.datetime.combine(dt.date(ano, mes, d), hora)
|
| 236 |
+
n += 1
|
| 237 |
+
else:
|
| 238 |
+
yield inicio
|
| 239 |
+
|
| 240 |
+
for c in candidatos():
|
| 241 |
+
if c < inicio:
|
| 242 |
+
continue
|
| 243 |
+
if ate is not None and c >= ate:
|
| 244 |
+
return
|
| 245 |
+
emitidos += 1
|
| 246 |
+
if contagem is not None and emitidos > contagem:
|
| 247 |
+
return
|
| 248 |
+
yield c
|
| 249 |
+
if emitidos >= LIMITE_ITERACOES:
|
| 250 |
+
return
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _dias_da_semana_do_mes(ano, mes, por_dia):
|
| 254 |
+
"""BYDAY com ordinal (2TU, -1FR) ou sem (todas as terças) dentro do mês."""
|
| 255 |
+
dias = []
|
| 256 |
+
ultimo = (dt.date(ano + (mes == 12), mes % 12 + 1, 1) - dt.timedelta(days=1)).day
|
| 257 |
+
for ordinal, dia in por_dia:
|
| 258 |
+
do_dia = [d for d in range(1, ultimo + 1) if dt.date(ano, mes, d).weekday() == dia]
|
| 259 |
+
if not ordinal:
|
| 260 |
+
dias += do_dia
|
| 261 |
+
elif 0 < ordinal <= len(do_dia):
|
| 262 |
+
dias.append(do_dia[ordinal - 1])
|
| 263 |
+
elif ordinal < 0 and -ordinal <= len(do_dia):
|
| 264 |
+
dias.append(do_dia[ordinal])
|
| 265 |
+
return dias
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def ocorrencias(eventos, de, ate):
|
| 269 |
+
"""[(inicio, fim, evento)] que tocam [de, ate), no horário local, em ordem."""
|
| 270 |
+
alteradas = {(e["uid"], e["id_instancia"]) for e in eventos if e["id_instancia"] is not None}
|
| 271 |
+
saida = []
|
| 272 |
+
for e in eventos:
|
| 273 |
+
for naive in _passos_da_regra(e):
|
| 274 |
+
local = naive if e["dia_inteiro"] else _local(naive, e["fuso"])
|
| 275 |
+
if local >= ate:
|
| 276 |
+
break # as ocorrências vêm em ordem: o resto é depois do período
|
| 277 |
+
if naive in e["excecoes"] or (e["uid"], naive) in alteradas:
|
| 278 |
+
continue
|
| 279 |
+
fim = local + e["duracao"]
|
| 280 |
+
if fim > de or (not e["duracao"] and local >= de):
|
| 281 |
+
saida.append((local, fim, e))
|
| 282 |
+
saida.sort(key=lambda x: (x[0], x[2]["titulo"]))
|
| 283 |
+
return saida
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def eventos_das_fontes(fontes=None, agora=None):
|
| 287 |
+
"""Lê os .ics escolhidos (arquivos ou links). Devolve (eventos, erros). O link tem cache de 15 minutos."""
|
| 288 |
+
eventos, erros = [], []
|
| 289 |
+
for fonte in (fontes if fontes is not None else agendas()):
|
| 290 |
+
try:
|
| 291 |
+
if re.match(r"https?://", fonte, re.I) or fonte.lower().startswith("webcal://"):
|
| 292 |
+
texto = _baixar(fonte.replace("webcal://", "https://", 1))
|
| 293 |
+
else:
|
| 294 |
+
texto = Path(fonte).expanduser().read_text(encoding="utf-8", errors="replace")
|
| 295 |
+
eventos += ler_ics(texto)
|
| 296 |
+
except Exception as e: # noqa: BLE001 (arquivo sumiu, sem rede: a resposta diz, o resto continua)
|
| 297 |
+
erros.append(f"{type(e).__name__}")
|
| 298 |
+
return eventos, erros
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _baixar(url):
|
| 302 |
+
guardado = _cache_link.get(url)
|
| 303 |
+
if guardado and time.monotonic() - guardado[0] < CACHE_LINK_S:
|
| 304 |
+
return guardado[1]
|
| 305 |
+
import requests
|
| 306 |
+
r = requests.get(url, timeout=10, headers={"User-Agent": "openTARS"})
|
| 307 |
+
r.raise_for_status()
|
| 308 |
+
_cache_link[url] = (time.monotonic(), r.text)
|
| 309 |
+
return r.text
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# ---------------------------------------------------------------- agenda: entender a frase
|
| 313 |
+
_AGENDA = re.compile(r"\b(agenda|compromiss\w*|calendar\w*|calendario|meetings?|events?|eventos?|reuni\w+|schedule)\b")
|
| 314 |
+
_TEMPO = re.compile(r"\b(hoje|today|amanha|tomorrow|depois de amanha|day after tomorrow|esta semana|essa semana|nesta semana|this week|"
|
| 315 |
+
r"semana que vem|proxima semana|next week|segunda|terca|quarta|quinta|sexta|sabado|domingo|monday|tuesday|wednesday|"
|
| 316 |
+
r"thursday|friday|saturday|sunday)\b")
|
| 317 |
+
_PROXIMO = re.compile(r"\b(proximo|proxima|next|upcoming)\b.*\b(compromisso|evento|reuniao|meeting|event|appointment)\b|"
|
| 318 |
+
r"\b(compromisso|evento|reuniao|meeting|event|appointment)\b.*\b(proximo|proxima|next)\b")
|
| 319 |
+
_NOMES_DIA = {"segunda": 0, "terca": 1, "quarta": 2, "quinta": 3, "sexta": 4, "sabado": 5, "domingo": 6,
|
| 320 |
+
"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6}
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _intervalo(n, hoje):
|
| 324 |
+
"""(início, fim, nome do período em pt/en) para a frase normalizada, ou None."""
|
| 325 |
+
dia = dt.datetime.combine(hoje, dt.time())
|
| 326 |
+
if re.search(r"\b(depois de amanha|day after tomorrow)\b", n):
|
| 327 |
+
return dia + dt.timedelta(days=2), dia + dt.timedelta(days=3), "depois"
|
| 328 |
+
if re.search(r"\b(amanha|tomorrow)\b", n):
|
| 329 |
+
return dia + dt.timedelta(days=1), dia + dt.timedelta(days=2), "amanha"
|
| 330 |
+
if re.search(r"\b(semana que vem|proxima semana|next week)\b", n):
|
| 331 |
+
segunda = dia + dt.timedelta(days=7 - hoje.weekday())
|
| 332 |
+
return segunda, segunda + dt.timedelta(days=7), "semana_que_vem"
|
| 333 |
+
if re.search(r"\b(esta semana|essa semana|nesta semana|this week)\b", n):
|
| 334 |
+
return dia, dia + dt.timedelta(days=7 - hoje.weekday()), "semana"
|
| 335 |
+
if re.search(r"\b(hoje|today)\b", n):
|
| 336 |
+
return dia, dia + dt.timedelta(days=1), "hoje"
|
| 337 |
+
for nome, numero in _NOMES_DIA.items():
|
| 338 |
+
if re.search(rf"\b{nome}\b", n):
|
| 339 |
+
alvo = dia + dt.timedelta(days=(numero - hoje.weekday()) % 7)
|
| 340 |
+
return alvo, alvo + dt.timedelta(days=1), ("dia", numero)
|
| 341 |
+
return None
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _periodo_texto(periodo, idioma, inicio):
|
| 345 |
+
if isinstance(periodo, tuple):
|
| 346 |
+
nome = TEXTOS["pt" if idioma == "pt" else "en"]["dias"][periodo[1]]
|
| 347 |
+
return (f"{'na' if periodo[1] < 5 else 'no'} {nome}" if idioma == "pt" else f"on {nome}") + f" ({inicio:%d/%m})"
|
| 348 |
+
return _t(periodo, idioma)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _hora(local, fim, inteiro, idioma):
|
| 352 |
+
if inteiro:
|
| 353 |
+
return _t("dia_inteiro", idioma)
|
| 354 |
+
return f"{local:%H:%M}" + (f"–{fim:%H:%M}" if fim != local and fim.date() == local.date() else "")
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def responder_agenda(pedido, idioma, agora=None, fontes=None):
|
| 358 |
+
"""O texto da resposta sobre a agenda, ou None quando a frase não é sobre a agenda (ou a agenda está desligada)."""
|
| 359 |
+
fontes = agendas() if fontes is None else fontes
|
| 360 |
+
n = normalizar(pedido)
|
| 361 |
+
if not fontes:
|
| 362 |
+
return None
|
| 363 |
+
proximo = bool(_PROXIMO.search(n))
|
| 364 |
+
intervalo = None if proximo else _intervalo(n, (agora or dt.datetime.now()).date())
|
| 365 |
+
pede = proximo or (intervalo is not None and bool(_AGENDA.search(n) or re.search(r"\b(tenho|do i have|have i|i have|what do i)\b", n)))
|
| 366 |
+
if not pede:
|
| 367 |
+
return None
|
| 368 |
+
eventos, erros = eventos_das_fontes(fontes)
|
| 369 |
+
if erros and not eventos:
|
| 370 |
+
return _t("erro", idioma, erro=erros[0])
|
| 371 |
+
agora = agora or dt.datetime.now()
|
| 372 |
+
if proximo:
|
| 373 |
+
for inicio, fim, e in ocorrencias(eventos, agora, agora + dt.timedelta(days=60)):
|
| 374 |
+
quando = f"{inicio:%d/%m} {_hora(inicio, fim, e['dia_inteiro'], idioma)}"
|
| 375 |
+
return _t("proximo", idioma, quando=quando, titulo=e["titulo"], local=f" ({e['local']})" if e["local"] else "")
|
| 376 |
+
return _t("sem_proximo", idioma)
|
| 377 |
+
de, ate, periodo = intervalo
|
| 378 |
+
achados = ocorrencias(eventos, de, ate)
|
| 379 |
+
nome = _periodo_texto(periodo, idioma, de)
|
| 380 |
+
if not achados:
|
| 381 |
+
return _t("nada", idioma, quando=nome)
|
| 382 |
+
cabeca = _t("um" if len(achados) == 1 else "varios", idioma, Quando=_cap(nome), n=len(achados))
|
| 383 |
+
linhas = [cabeca]
|
| 384 |
+
multi_dia = (ate - de).days > 1
|
| 385 |
+
for inicio, fim, e in achados[:LIMITE_LISTA]:
|
| 386 |
+
dia = f"{TEXTOS['pt' if idioma == 'pt' else 'en']['dias'][inicio.weekday()][:3]} {inicio:%d/%m} " if multi_dia else ""
|
| 387 |
+
linhas.append(f"• {dia}{_hora(inicio, fim, e['dia_inteiro'], idioma)} {e['titulo']}" + (f" ({e['local']})" if e["local"] else ""))
|
| 388 |
+
if len(achados) > LIMITE_LISTA:
|
| 389 |
+
linhas.append(_t("mais", idioma, n=len(achados) - LIMITE_LISTA))
|
| 390 |
+
return "\n".join(linhas)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _cap(s):
|
| 394 |
+
return s[:1].upper() + s[1:]
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
# ---------------------------------------------------------------- notas em Markdown
|
| 398 |
+
_CRIAR_NOTA = re.compile(r"^\s*(?:por favor,?\s+|please,?\s+)?(?:cri[ae]r?|fa[zç]a?|salv[ae]|escrev[ae]r?|anot[ae]r?|nova|create|make|take|new|add|save|write)\s+"
|
| 399 |
+
r"(?:(?:uma|a|an)\s+)?(?:(?:nova|new)\s+)?n[oó]ta\b\s*(?:sobre|de|com|dizendo|que|about|saying|that|:|-)?\s*:?\s*(?P<corpo>.*)$",
|
| 400 |
+
re.IGNORECASE | re.DOTALL)
|
| 401 |
+
_CRIAR_NOTA_EN = re.compile(r"^\s*(?:please,?\s+)?(?:create|make|take|new|add|save|write)\s+(?:(?:a|an)\s+)?(?:(?:new)\s+)?note\b\s*(?:about|saying|that|:|-)?\s*:?\s*(?P<corpo>.*)$",
|
| 402 |
+
re.IGNORECASE | re.DOTALL)
|
| 403 |
+
_ACHAR_NOTA = re.compile(r"\b(?:minhas\s+)?notas?\s+(?:sobre|de|com|about|on|for)\s+(?P<termo>.+)$|"
|
| 404 |
+
r"\b(?:procura|busca|ache|acha|procure|busque|search|find|look)\w*\s+(?:nas?\s+)?(?:minhas\s+)?(?:notas|my notes|in my notes|notes)\s+(?P<termo2>.+)$", re.IGNORECASE)
|
| 405 |
+
_LER_NOTA = re.compile(r"^\s*(?:abre|abra|mostra|mostre|le|ler|leia|show|open|read)\s+(?:a\s+|the\s+|minha\s+|my\s+)*(?:nota|note)\s+(?P<termo>.+?)\s*$", re.IGNORECASE)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def _nome_de_arquivo(texto):
|
| 409 |
+
base = normalizar(texto.strip().splitlines()[0] if texto.strip() else "nota")
|
| 410 |
+
slug = "-".join(re.findall(r"[a-z0-9]+", base)[:7]) or "nota"
|
| 411 |
+
return slug[:60]
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def criar_nota(corpo, pasta=None, agora=None):
|
| 415 |
+
"""Grava uma nota nova e devolve o caminho. Nunca escreve por cima: se o nome existe, ganha -2, -3..."""
|
| 416 |
+
pasta = Path(pasta or pasta_de_notas())
|
| 417 |
+
pasta.mkdir(parents=True, exist_ok=True)
|
| 418 |
+
corpo = corpo.strip()
|
| 419 |
+
titulo = corpo.splitlines()[0][:80]
|
| 420 |
+
base = f"{(agora or dt.datetime.now()):%Y-%m-%d}-{_nome_de_arquivo(corpo)}"
|
| 421 |
+
destino, k = pasta / f"{base}.md", 2
|
| 422 |
+
while destino.exists():
|
| 423 |
+
destino = pasta / f"{base}-{k}.md"
|
| 424 |
+
k += 1
|
| 425 |
+
resto = "\n".join(corpo.splitlines()[1:]).strip()
|
| 426 |
+
destino.write_text(f"# {titulo}\n\n" + (resto + "\n" if resto else ""), encoding="utf-8")
|
| 427 |
+
return destino
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def achar_notas(termo, pasta=None, limite=5):
|
| 431 |
+
import tars_busca_arquivos as busca
|
| 432 |
+
pasta = Path(pasta or pasta_de_notas())
|
| 433 |
+
if not pasta.is_dir():
|
| 434 |
+
return []
|
| 435 |
+
achados = busca.buscar(termo, raiz=pasta, limite=limite * 2, indice=False)
|
| 436 |
+
return [a for a in achados if a["nome"].lower().endswith((".md", ".txt"))][:limite]
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def responder_notas(pedido, idioma, pasta=None):
|
| 440 |
+
"""Cria, procura ou lê notas. None quando a frase não é de nota, ou as notas estão desligadas (sem pasta)."""
|
| 441 |
+
pasta = pasta or pasta_de_notas()
|
| 442 |
+
if pasta is None:
|
| 443 |
+
return None
|
| 444 |
+
m = _CRIAR_NOTA.match(pedido) or _CRIAR_NOTA_EN.match(pedido)
|
| 445 |
+
if m:
|
| 446 |
+
corpo = (m.group("corpo") or "").strip()
|
| 447 |
+
if not corpo:
|
| 448 |
+
return _t("nota_vazia", idioma)
|
| 449 |
+
destino = criar_nota(corpo, pasta)
|
| 450 |
+
return _t("nota_criada", idioma, nome=destino.name, pasta=str(destino.parent).replace(str(Path.home()), "~", 1))
|
| 451 |
+
m = _LER_NOTA.match(pedido)
|
| 452 |
+
if m:
|
| 453 |
+
termo = m.group("termo").strip(" ?.!")
|
| 454 |
+
achados = achar_notas(termo, pasta, 1)
|
| 455 |
+
if not achados:
|
| 456 |
+
return _t("nota_nao_achei", idioma, termo=termo)
|
| 457 |
+
texto = Path(achados[0]["caminho"]).read_text(encoding="utf-8", errors="replace").strip()
|
| 458 |
+
return _t("nota_texto", idioma, nome=achados[0]["nome"], texto=texto[:900] + ("…" if len(texto) > 900 else ""))
|
| 459 |
+
m = _ACHAR_NOTA.search(pedido)
|
| 460 |
+
if m:
|
| 461 |
+
termo = (m.group("termo") or m.group("termo2") or "").strip(" ?.!")
|
| 462 |
+
if not termo:
|
| 463 |
+
return None
|
| 464 |
+
achados = achar_notas(termo, pasta)
|
| 465 |
+
if not achados:
|
| 466 |
+
return _t("notas_nada", idioma, termo=termo)
|
| 467 |
+
linhas = [_t("notas_achei", idioma, n=len(achados))]
|
| 468 |
+
for a in achados:
|
| 469 |
+
linhas.append(f"• {a['nome']}" + (f": {a['trecho']}" if a["trecho"] else ""))
|
| 470 |
+
return "\n".join(linhas)
|
| 471 |
+
return None
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def responder(pedido, idioma):
|
| 475 |
+
"""Agenda e notas por código; None se a frase não é com elas (ou as duas estão desligadas, o padrão)."""
|
| 476 |
+
return responder_notas(pedido, idioma) or responder_agenda(pedido, idioma)
|
app/tars_lembretes.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lembretes e timers por frase (F4++ do PLANO_5.3.md, 07/11), sem o modelo.
|
| 2 |
+
|
| 3 |
+
Medido antes: "me lembra de tomar água daqui a 30 minutos" virava uma memória ("Vou lembrar"), e "põe um timer de 5 minutos" tentava
|
| 4 |
+
programas que não existem e dizia que o timer estava rodando. Nenhum aviso era agendado.
|
| 5 |
+
Aqui a frase vira uma rotina de uma vez na agenda que o app já tem (tars_rotinas); na hora, o pedido "Lembrete: ..." sai por regra
|
| 6 |
+
(resposta_da_rotina) e a janela avisa como avisa qualquer rotina. Só dispara com o openTARS aberto (ou na bandeja).
|
| 7 |
+
"""
|
| 8 |
+
import datetime as dt
|
| 9 |
+
import re
|
| 10 |
+
import unicodedata
|
| 11 |
+
|
| 12 |
+
import tars_rotinas
|
| 13 |
+
|
| 14 |
+
_NUMEROS = {"um": 1, "uma": 1, "dois": 2, "duas": 2, "tres": 3, "quatro": 4, "cinco": 5, "seis": 6, "sete": 7, "oito": 8, "nove": 9, "dez": 10,
|
| 15 |
+
"quinze": 15, "vinte": 20, "trinta": 30, "quarenta": 40, "meia": 30, "a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "five": 5,
|
| 16 |
+
"ten": 10, "fifteen": 15, "twenty": 20, "thirty": 30}
|
| 17 |
+
_N = r"(\d+|um|uma|dois|duas|tres|quatro|cinco|seis|sete|oito|nove|dez|quinze|vinte|trinta|quarenta|a|an|one|two|three|five|ten|fifteen|twenty|thirty)"
|
| 18 |
+
_PEDE = re.compile(r"^(?:por favor |please |pode |can you )?(?:me lembra|me lembre|lembra-me|me avisa|me avise|avisa-me|remind me|ping me)\b")
|
| 19 |
+
_PEDE_SEM_DUVIDA = re.compile(r"^(?:por favor |please |pode |can you )?(?:me lembra|me lembre|lembra-me|remind me)\b")
|
| 20 |
+
_TIMER = re.compile(r"\b(?:timer|temporizador|cronometro|alarme|alarm)\b")
|
| 21 |
+
_DAQUI = re.compile(r"\b(?:daqui a|daqui|em|dentro de|in|after)\s+" + _N + r"\s*(minutos?|minutes?|mins?|horas?|h|hours?|segundos?|seconds?|s)\b")
|
| 22 |
+
_DURACAO = re.compile(_N + r"[\s-]*(minutos?|minutes?|mins?|horas?|hours?|h|segundos?|seconds?|s)\b")
|
| 23 |
+
_HORA = re.compile(r"\b(?:as|a|at|pras|para as|ao meio)\s*(\d{1,2})(?:[:h](\d{2}))?\s*(?:h|horas)?\s*(am|pm|da manha|da tarde|da noite)?\b|\b(\d{1,2})\s*(am|pm)\b|\b(meio[- ]dia|noon|midnight|meia[- ]noite)\b")
|
| 24 |
+
_AMANHA = re.compile(r"\b(amanha|tomorrow)\b")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _sem_acento(texto):
|
| 28 |
+
return "".join(c for c in unicodedata.normalize("NFKD", texto) if not unicodedata.combining(c)).lower().strip()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _numero(texto):
|
| 32 |
+
return int(texto) if texto.isdigit() else _NUMEROS.get(texto, 1)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _segundos(numero, unidade):
|
| 36 |
+
u = unidade[0]
|
| 37 |
+
return _numero(numero) * (3600 if u == "h" else 1 if u == "s" else 60)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _quando(n, agora):
|
| 41 |
+
"""O datetime pedido, ou None se a frase não diz quando."""
|
| 42 |
+
a = _DAQUI.search(n)
|
| 43 |
+
if a:
|
| 44 |
+
return agora + dt.timedelta(seconds=_segundos(a.group(1), a.group(2)))
|
| 45 |
+
h = _HORA.search(n)
|
| 46 |
+
if not h:
|
| 47 |
+
return None
|
| 48 |
+
if h.group(6):
|
| 49 |
+
hora, minuto = (12, 0) if h.group(6).startswith(("meio", "noon")) else (0, 0)
|
| 50 |
+
else:
|
| 51 |
+
hora = int(h.group(1) or h.group(4))
|
| 52 |
+
minuto = int(h.group(2) or 0)
|
| 53 |
+
periodo = h.group(3) or h.group(5) or ""
|
| 54 |
+
if periodo in ("pm", "da tarde", "da noite") and hora < 12:
|
| 55 |
+
hora += 12
|
| 56 |
+
if periodo in ("am", "da manha") and hora == 12:
|
| 57 |
+
hora = 0
|
| 58 |
+
if hora > 23 or minuto > 59:
|
| 59 |
+
return None
|
| 60 |
+
alvo = agora.replace(hour=hora, minute=minuto, second=0, microsecond=0)
|
| 61 |
+
if _AMANHA.search(n):
|
| 62 |
+
alvo += dt.timedelta(days=1)
|
| 63 |
+
elif alvo <= agora:
|
| 64 |
+
alvo += dt.timedelta(days=1)
|
| 65 |
+
return alvo
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _o_que(original, n):
|
| 69 |
+
"""O texto do lembrete, com os acentos e as maiúsculas de quem escreveu: o pedido sem o "me lembra" e sem o quando."""
|
| 70 |
+
base = original.strip() if len(original.strip()) == len(n) else n
|
| 71 |
+
apagar = [False] * len(n)
|
| 72 |
+
for padrao in (_PEDE, _DAQUI, _HORA, _AMANHA):
|
| 73 |
+
for m in padrao.finditer(n):
|
| 74 |
+
for k in range(m.start(), m.end()):
|
| 75 |
+
apagar[k] = True
|
| 76 |
+
texto = " ".join("".join(" " if apagar[k] else c for k, c in enumerate(base)).split())
|
| 77 |
+
texto = re.sub(r"^(?:de|que|para|pra|to|that|about)\s+", "", texto, flags=re.IGNORECASE)
|
| 78 |
+
return re.sub(r"\s+(?:de|para|pra|to|e|and)$", "", texto, flags=re.IGNORECASE).strip(" ,.!?")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def entender(texto, agora=None):
|
| 82 |
+
"""{'tipo': 'lembrete'|'timer', 'quando': datetime, 'texto': ...}, {'tipo': 'falta_hora'} ou None."""
|
| 83 |
+
n = _sem_acento(texto)
|
| 84 |
+
agora = agora or dt.datetime.now().replace(microsecond=0)
|
| 85 |
+
if _TIMER.search(n) and re.search(r"\b(poe|ponha|coloca|coloque|liga|ligue|cria|crie|marca|marque|faz|set|start|put|make|timer de|timer for)\b|^timer\b", n):
|
| 86 |
+
d = _DURACAO.search(n)
|
| 87 |
+
if not d:
|
| 88 |
+
return {"tipo": "falta_hora"}
|
| 89 |
+
segundos = _segundos(d.group(1), d.group(2))
|
| 90 |
+
rotulo = f"{_numero(d.group(1))} {d.group(2)}"
|
| 91 |
+
return {"tipo": "timer", "quando": agora + dt.timedelta(seconds=segundos), "texto": rotulo}
|
| 92 |
+
if not _PEDE.search(n):
|
| 93 |
+
return None
|
| 94 |
+
quando = _quando(n, agora)
|
| 95 |
+
if quando is None: # "me avisa se..." sem hora não é lembrete; "me lembra de..." sem hora pergunta quando
|
| 96 |
+
return {"tipo": "falta_hora"} if _PEDE_SEM_DUVIDA.search(n) else None
|
| 97 |
+
return {"tipo": "lembrete", "quando": quando, "texto": _o_que(texto, n) or ("isso" if re.search(r"[ãçáé]|\bde\b", n) else "this")}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def agendar(pedido, idioma, agenda=None):
|
| 101 |
+
"""Cria a rotina e devolve a frase de confirmação; None se a frase não é de lembrete nem de timer."""
|
| 102 |
+
e = entender(pedido)
|
| 103 |
+
if e is None:
|
| 104 |
+
return None
|
| 105 |
+
pt = idioma == "pt"
|
| 106 |
+
if e["tipo"] == "falta_hora":
|
| 107 |
+
return "Quando te lembro? Por exemplo: daqui a 30 minutos, ou amanhã às 9h." if pt else "When should I remind you? For example: in 30 minutes, or tomorrow at 9 am."
|
| 108 |
+
agenda = agenda or tars_rotinas.Agenda()
|
| 109 |
+
if e["tipo"] == "timer":
|
| 110 |
+
nome, aviso = (f"Timer de {e['texto']}", f"Timer: {e['texto']} terminaram.") if pt else (f"{e['texto']} timer", f"Timer: {e['texto']} are up.")
|
| 111 |
+
else:
|
| 112 |
+
nome, aviso = (("Lembrete: " if pt else "Reminder: ") + e["texto"])[:tars_rotinas.LIMITE_NOME], ("Lembrete: " if pt else "Reminder: ") + e["texto"]
|
| 113 |
+
rotina, erro = agenda.criar(nome, aviso, {"tipo": "uma_vez", "data": e["quando"].isoformat()}, ativa=True, aprovada=True, origem="pessoa")
|
| 114 |
+
if erro:
|
| 115 |
+
return ("Não consegui agendar (" + erro + ")." if pt else "I couldn't schedule it (" + erro + ").")
|
| 116 |
+
dia = "" if e["quando"].date() == dt.date.today() else (" de amanhã" if pt else " tomorrow") if e["quando"].date() == dt.date.today() + dt.timedelta(days=1) \
|
| 117 |
+
else f" de {e['quando']:%d/%m}" if pt else f" on {e['quando']:%m/%d}"
|
| 118 |
+
hora = f"{e['quando']:%H:%M}"
|
| 119 |
+
if e["tipo"] == "timer":
|
| 120 |
+
return f"Timer de {e['texto']} ligado: toca às {hora}{dia}." if pt else f"{e['texto']} timer set: it goes off at {hora}{dia}."
|
| 121 |
+
return f"Combinado: às {hora}{dia} eu te lembro de {e['texto']}." if pt else f"Got it: at {hora}{dia} I'll remind you to {e['texto']}."
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
_AVISO = re.compile(r"^(lembrete|reminder|timer):\s*(.+)$", re.IGNORECASE)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def resposta_da_rotina(pedido):
|
| 128 |
+
"""Quando a rotina dispara, o pedido "Lembrete: X" volta como o próprio aviso, sem o modelo."""
|
| 129 |
+
a = _AVISO.match((pedido or "").strip())
|
| 130 |
+
return f"⏰ {pedido.strip()}" if a else None
|
app/tars_mais_modelos.py
CHANGED
|
@@ -10,9 +10,10 @@ Se um tamanho mudar lá, é aqui que se corrige.
|
|
| 10 |
GRUPOS = (
|
| 11 |
# 25/10 (decisão do Enzo): os modelos do openTARS no topo, o Lite primeiro e destacado (é o que fica em segundo plano)
|
| 12 |
("opentars", (
|
| 13 |
-
("ProjectOpenTARS/Gargantua-2:lite", "Gargantua
|
| 14 |
-
("ProjectOpenTARS/Gargantua-2:latest", "Gargantua 2",
|
| 15 |
-
("ProjectOpenTARS/Gargantua:
|
|
|
|
| 16 |
)),
|
| 17 |
("bonsai", (
|
| 18 |
("bonsai-2:2b", "Bonsai 2 · 2B", 0.911, ("vision", "tools", "thinking")),
|
|
@@ -90,7 +91,7 @@ GRUPOS = (
|
|
| 90 |
)
|
| 91 |
|
| 92 |
CAPACIDADES = ("vision", "tools", "thinking", "image", "text")
|
| 93 |
-
DESTAQUE = "ProjectOpenTARS/Gargantua-2:lite"
|
| 94 |
|
| 95 |
# Etiquetas que o Ollama mostra com outro nome quando o modelo foi baixado sem a tag (o mesmo arquivo).
|
| 96 |
_APELIDOS = {
|
|
@@ -117,10 +118,6 @@ _APELIDOS = {
|
|
| 117 |
}
|
| 118 |
|
| 119 |
|
| 120 |
-
def todas_as_tags():
|
| 121 |
-
return [item[0] for _, itens in GRUPOS for item in itens]
|
| 122 |
-
|
| 123 |
-
|
| 124 |
def _norma(tag):
|
| 125 |
tag = tag.strip().lower()
|
| 126 |
return tag if ":" in tag else tag + ":latest"
|
|
@@ -133,6 +130,8 @@ def instalado(tag, instalados):
|
|
| 133 |
|
| 134 |
|
| 135 |
def tamanho_texto(gb):
|
|
|
|
|
|
|
| 136 |
return f"{round(gb * 1000)} MB" if gb < 1 else f"{gb:.1f} GB"
|
| 137 |
|
| 138 |
|
|
@@ -186,3 +185,55 @@ def recomendados(hw):
|
|
| 186 |
if cabem:
|
| 187 |
achados.append(max(cabem, key=lambda i: ("tools" in i[3], i[2], "thinking" not in i[3]))[0])
|
| 188 |
return achados
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
GRUPOS = (
|
| 11 |
# 25/10 (decisão do Enzo): os modelos do openTARS no topo, o Lite primeiro e destacado (é o que fica em segundo plano)
|
| 12 |
("opentars", (
|
| 13 |
+
("ProjectOpenTARS/Gargantua-2.5:lite", "Gargantua Lite Gen 3", 4.4, ("vision", "tools")),
|
| 14 |
+
("ProjectOpenTARS/Gargantua-2.5:latest", "Gargantua 2.5", 6.3, ("vision", "tools")),
|
| 15 |
+
("ProjectOpenTARS/Gargantua-Pro:pro", "Gargantua Pro Gen 1", 7.6, ("vision", "tools")),
|
| 16 |
+
("ProjectOpenTARS/Gargantua-Code-Gen2:code", "Gargantua Code Gen 2", 4.7, ("tools",)),
|
| 17 |
)),
|
| 18 |
("bonsai", (
|
| 19 |
("bonsai-2:2b", "Bonsai 2 · 2B", 0.911, ("vision", "tools", "thinking")),
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
CAPACIDADES = ("vision", "tools", "thinking", "image", "text")
|
| 94 |
+
DESTAQUE = "ProjectOpenTARS/Gargantua-2.5:lite"
|
| 95 |
|
| 96 |
# Etiquetas que o Ollama mostra com outro nome quando o modelo foi baixado sem a tag (o mesmo arquivo).
|
| 97 |
_APELIDOS = {
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def _norma(tag):
|
| 122 |
tag = tag.strip().lower()
|
| 123 |
return tag if ":" in tag else tag + ":latest"
|
|
|
|
| 130 |
|
| 131 |
|
| 132 |
def tamanho_texto(gb):
|
| 133 |
+
if not gb:
|
| 134 |
+
return "?" # resultado da pesquisa na biblioteca: o tamanho só se sabe ao clicar
|
| 135 |
return f"{round(gb * 1000)} MB" if gb < 1 else f"{gb:.1f} GB"
|
| 136 |
|
| 137 |
|
|
|
|
| 185 |
if cabem:
|
| 186 |
achados.append(max(cabem, key=lambda i: ("tools" in i[3], i[2], "thinking" not in i[3]))[0])
|
| 187 |
return achados
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------- 11/11: pesquisa na loja
|
| 191 |
+
|
| 192 |
+
def filtrar(itens, consulta):
|
| 193 |
+
"""Os itens (tag, nome, gb, capacidades) em que todas as palavras da consulta aparecem no nome, na tag ou nas capacidades."""
|
| 194 |
+
palavras = str(consulta or "").lower().split()
|
| 195 |
+
return [i for i in itens if all(p in " ".join((i[0].split("/")[-1], i[1], *i[3])).lower() for p in palavras)] # sem o dono: "pro" não casa com ProjectOpenTARS
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _http(url, tempo=10):
|
| 199 |
+
import urllib.request
|
| 200 |
+
pedido = urllib.request.Request(url, headers={"User-Agent": "openTARS"})
|
| 201 |
+
with urllib.request.urlopen(pedido, timeout=tempo) as r:
|
| 202 |
+
return r.read(2_000_000).decode("utf-8", "replace")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
_CAPACIDADES_CONHECIDAS = ("vision", "tools", "thinking")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def buscar_biblioteca(consulta, http=_http):
|
| 209 |
+
"""Procura na biblioteca do Ollama (ollama.com/search). Devolve [{nome, descricao, capacidades, tamanhos}], só o que roda
|
| 210 |
+
local: modelos "cloud" e de usuário ficam de fora. Falha de rede levanta a exceção do `http`."""
|
| 211 |
+
import re
|
| 212 |
+
from urllib.parse import quote
|
| 213 |
+
pagina = http("https://ollama.com/search?q=" + quote(str(consulta).strip()))
|
| 214 |
+
achados = []
|
| 215 |
+
for bloco in pagina.split('<li class="border-t')[1:]:
|
| 216 |
+
m = re.search(r'href="/library/([a-z0-9][\w.\-]*)"', bloco)
|
| 217 |
+
if not m:
|
| 218 |
+
continue
|
| 219 |
+
marcas = re.findall(r'>([^<>]{1,24})</span>', bloco)
|
| 220 |
+
descricao = re.search(r'<p class="[^"]*">([^<]+)</p>', bloco)
|
| 221 |
+
achados.append({
|
| 222 |
+
"nome": m.group(1),
|
| 223 |
+
"descricao": (descricao.group(1).strip() if descricao else ""),
|
| 224 |
+
"capacidades": tuple(c for c in _CAPACIDADES_CONHECIDAS if c in marcas),
|
| 225 |
+
"tamanhos": tuple(x for x in marcas if re.fullmatch(r"(e?\d+(\.\d+)?[bm]|latest)", x)),
|
| 226 |
+
})
|
| 227 |
+
return achados
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def tamanho_da_tag(nome_tag, http=_http):
|
| 231 |
+
"""Tamanho do download (GB) de `nome:tag` segundo a página de tags da biblioteca, ou None se não achar."""
|
| 232 |
+
import re
|
| 233 |
+
nome, _, tag = nome_tag.partition(":")
|
| 234 |
+
pagina = http(f"https://ollama.com/library/{nome}/tags")
|
| 235 |
+
m = re.search(r'href="/library/' + re.escape(nome_tag) + r'"(.{0,900}?)([\d.]+)\s?(GB|MB)', pagina, re.S)
|
| 236 |
+
if not m:
|
| 237 |
+
return None
|
| 238 |
+
valor = float(m.group(2))
|
| 239 |
+
return valor if m.group(3) == "GB" else valor / 1000
|
app/tars_midia.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""5.3 (F4+++): controle de música e vídeo por frase, sem o modelo: "pausa a música", "próxima faixa", "que música está tocando?".
|
| 2 |
+
|
| 3 |
+
Linux: qualquer player que fale MPRIS (Spotify, VLC, Rhythmbox, Firefox, Chromium, o YouTube no navegador...) pelo `gdbus`, que vem
|
| 4 |
+
com o GNOME e o glib. Windows: as teclas de mídia do sistema (tocar/pausar, próxima, anterior); o Windows não deixa um programa
|
| 5 |
+
simples ler o que está tocando, então "que música é?" diz que não sabe. macOS: Spotify e Music pelo `osascript`.
|
| 6 |
+
Só age quando a frase pede (nunca sozinho), e nunca abre nem fecha programas. Não foi usado com um player de verdade fora do Linux.
|
| 7 |
+
"""
|
| 8 |
+
import re
|
| 9 |
+
import subprocess
|
| 10 |
+
import sys
|
| 11 |
+
import unicodedata
|
| 12 |
+
|
| 13 |
+
import tars_plataforma as plataforma
|
| 14 |
+
|
| 15 |
+
TEXTOS = {
|
| 16 |
+
"pt": {"play_pause": "Pronto.", "pause": "Pausei.", "play": "Toquei.", "next": "Próxima.", "previous": "Voltei.", "stop": "Parei.",
|
| 17 |
+
"nenhum": "Não achei nenhum player de música ou vídeo aberto.", "tocando": "Tocando agora: {titulo}{artista} ({app}).",
|
| 18 |
+
"pausado": "Está pausado: {titulo}{artista} ({app}).", "nada": "Não tem nada tocando agora.",
|
| 19 |
+
"nao_sei": "No Windows eu consigo pausar, tocar e pular, mas não sei dizer o que está tocando.", "falhou": "Não consegui controlar o player ({erro})."},
|
| 20 |
+
"en": {"play_pause": "Done.", "pause": "Paused.", "play": "Playing.", "next": "Next.", "previous": "Back.", "stop": "Stopped.",
|
| 21 |
+
"nenhum": "I didn't find any music or video player open.", "tocando": "Now playing: {titulo}{artista} ({app}).",
|
| 22 |
+
"pausado": "Paused: {titulo}{artista} ({app}).", "nada": "Nothing is playing right now.",
|
| 23 |
+
"nao_sei": "On Windows I can pause, play and skip, but I can't tell what is playing.", "falhou": "I couldn't control the player ({erro})."},
|
| 24 |
+
}
|
| 25 |
+
METODOS = {"play_pause": "PlayPause", "pause": "Pause", "play": "Play", "next": "Next", "previous": "Previous", "stop": "Stop"}
|
| 26 |
+
TECLAS_WINDOWS = {"play_pause": 0xB3, "pause": 0xB3, "play": 0xB3, "next": 0xB0, "previous": 0xB1, "stop": 0xB2}
|
| 27 |
+
COMANDOS_MAC = {"play_pause": "playpause", "pause": "pause", "play": "play", "next": "next track", "previous": "previous track", "stop": "pause"}
|
| 28 |
+
PLAYERS_MAC = ("Spotify", "Music")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _t(chave, idioma, **kw):
|
| 32 |
+
s = TEXTOS["pt" if idioma == "pt" else "en"][chave]
|
| 33 |
+
return s.format(**kw) if kw else s
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def normalizar(texto):
|
| 37 |
+
return unicodedata.normalize("NFKD", str(texto)).encode("ascii", "ignore").decode().lower().strip(" .!?,")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _rodar(comando, tempo=4):
|
| 41 |
+
"""O texto de saída, ou levanta OSError / SubprocessError / RuntimeError. (Os testes trocam esta função por uma falsa.)"""
|
| 42 |
+
r = subprocess.run(comando, capture_output=True, text=True, timeout=tempo)
|
| 43 |
+
if r.returncode != 0:
|
| 44 |
+
raise RuntimeError((r.stderr or r.stdout or f"código {r.returncode}").strip()[:120])
|
| 45 |
+
return r.stdout
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------- Linux: MPRIS pelo gdbus
|
| 49 |
+
def _players_mpris():
|
| 50 |
+
saida = _rodar(["gdbus", "call", "--session", "--dest", "org.freedesktop.DBus", "--object-path", "/org/freedesktop/DBus",
|
| 51 |
+
"--method", "org.freedesktop.DBus.ListNames"])
|
| 52 |
+
return [n for n in re.findall(r"'(org\.mpris\.MediaPlayer2\.[^']+)'", saida)]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _propriedade(player, nome):
|
| 56 |
+
return _rodar(["gdbus", "call", "--session", "--dest", player, "--object-path", "/org/mpris/MediaPlayer2", "--method",
|
| 57 |
+
"org.freedesktop.DBus.Properties.Get", "org.mpris.MediaPlayer2.Player", nome])
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _estado(player):
|
| 61 |
+
"""'Playing', 'Paused' ou 'Stopped'; '' se não respondeu."""
|
| 62 |
+
try:
|
| 63 |
+
m = re.search(r"'(Playing|Paused|Stopped)'", _propriedade(player, "PlaybackStatus"))
|
| 64 |
+
except (OSError, RuntimeError, subprocess.SubprocessError):
|
| 65 |
+
return ""
|
| 66 |
+
return m.group(1) if m else ""
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _escolher_player(players):
|
| 70 |
+
"""O que está tocando; senão o que está pausado; senão o primeiro."""
|
| 71 |
+
estados = {p: _estado(p) for p in players}
|
| 72 |
+
for desejado in ("Playing", "Paused"):
|
| 73 |
+
for p, e in estados.items():
|
| 74 |
+
if e == desejado:
|
| 75 |
+
return p
|
| 76 |
+
return players[0] if players else None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _nome_do_player(player):
|
| 80 |
+
nome = player.split("org.mpris.MediaPlayer2.", 1)[-1]
|
| 81 |
+
return re.sub(r"\.instance\d+$", "", nome).capitalize()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _metadados(player):
|
| 85 |
+
texto = _propriedade(player, "Metadata")
|
| 86 |
+
titulo = re.search(r"'xesam:title': <'((?:[^'\\]|\\.)*)'>", texto)
|
| 87 |
+
artista = re.search(r"'xesam:artist': <\['((?:[^'\\]|\\.)*)'", texto)
|
| 88 |
+
desescapar = lambda s: s.replace("\\'", "'").replace("\\\\", "\\") # noqa: E731
|
| 89 |
+
return (desescapar(titulo.group(1)) if titulo else "", desescapar(artista.group(1)) if artista else "")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _linux(acao, idioma):
|
| 93 |
+
players = _players_mpris()
|
| 94 |
+
if not players:
|
| 95 |
+
return _t("nenhum", idioma)
|
| 96 |
+
player = _escolher_player(players)
|
| 97 |
+
if acao == "agora":
|
| 98 |
+
titulo, artista = _metadados(player)
|
| 99 |
+
estado = _estado(player)
|
| 100 |
+
if not titulo or estado == "Stopped":
|
| 101 |
+
return _t("nada", idioma)
|
| 102 |
+
return _t("tocando" if estado == "Playing" else "pausado", idioma, titulo=titulo,
|
| 103 |
+
artista=f", {artista}" if artista else "", app=_nome_do_player(player))
|
| 104 |
+
_rodar(["gdbus", "call", "--session", "--dest", player, "--object-path", "/org/mpris/MediaPlayer2", "--method",
|
| 105 |
+
f"org.mpris.MediaPlayer2.Player.{METODOS[acao]}"])
|
| 106 |
+
return _t(acao, idioma)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ---------------------------------------------------------------- Windows e macOS
|
| 110 |
+
def _windows(acao, idioma):
|
| 111 |
+
if acao == "agora":
|
| 112 |
+
return _t("nao_sei", idioma)
|
| 113 |
+
import ctypes
|
| 114 |
+
tecla = TECLAS_WINDOWS[acao]
|
| 115 |
+
ctypes.windll.user32.keybd_event(tecla, 0, 0, 0) # aperta e solta a tecla de mídia
|
| 116 |
+
ctypes.windll.user32.keybd_event(tecla, 0, 2, 0)
|
| 117 |
+
return _t(acao, idioma)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _mac(acao, idioma):
|
| 121 |
+
aberto = [p for p in PLAYERS_MAC if _rodar(["osascript", "-e", f'application "{p}" is running']).strip() == "true"]
|
| 122 |
+
if not aberto:
|
| 123 |
+
return _t("nenhum", idioma)
|
| 124 |
+
player = aberto[0]
|
| 125 |
+
if acao == "agora":
|
| 126 |
+
estado = _rodar(["osascript", "-e", f'tell application "{player}" to player state as string']).strip()
|
| 127 |
+
if estado not in ("playing", "paused"):
|
| 128 |
+
return _t("nada", idioma)
|
| 129 |
+
titulo = _rodar(["osascript", "-e", f'tell application "{player}" to name of current track']).strip()
|
| 130 |
+
artista = _rodar(["osascript", "-e", f'tell application "{player}" to artist of current track']).strip()
|
| 131 |
+
return _t("tocando" if estado == "playing" else "pausado", idioma, titulo=titulo, artista=f", {artista}" if artista else "", app=player)
|
| 132 |
+
_rodar(["osascript", "-e", f'tell application "{player}" to {COMANDOS_MAC[acao]}'])
|
| 133 |
+
return _t(acao, idioma)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def agir(acao, idioma="pt"):
|
| 137 |
+
"""Faz a ação ("play_pause", "pause", "play", "next", "previous", "stop" ou "agora") e devolve a frase da resposta."""
|
| 138 |
+
try:
|
| 139 |
+
sistema = plataforma.sistema()
|
| 140 |
+
return {"windows": _windows, "macos": _mac}.get(sistema, _linux)(acao, idioma)
|
| 141 |
+
except (OSError, RuntimeError, subprocess.SubprocessError, AttributeError) as e:
|
| 142 |
+
return _t("falhou", idioma, erro=str(e)[:80] or type(e).__name__)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ---------------------------------------------------------------- entender a frase
|
| 146 |
+
_MIDIA = r"(musica|musicas|faixa|song|songs|track|music|player|spotify|video|videos|podcast)"
|
| 147 |
+
_ACOES = (
|
| 148 |
+
("agora", re.compile(rf"^(que|qual|what)\b.*\b{_MIDIA}\b.*\b(tocando|esta tocando|playing|is this)\b|^(what'?s|what is) (this song|playing( now)?)$|"
|
| 149 |
+
r"^(que|qual) (musica|faixa) (e|eh|esta tocando)( essa| esta| agora)?$|^o que esta tocando( agora)?$")),
|
| 150 |
+
("next", re.compile(rf"^(pula|pule|passa|passe|skip|next)\b( a| o| the)? {_MIDIA}$|^(proxima|proximo|next) (musica|faixa|song|track|video)$|"
|
| 151 |
+
r"^(vai|va|ir) (para|pra|a|to) (a )?(proxima|next)( (musica|faixa|song|track))?$")),
|
| 152 |
+
("previous", re.compile(rf"^(volta|volte|previous|go back)\b( a| o| the)? {_MIDIA}$|^(musica|faixa) anterior$|^(previous|last) (song|track|music)$")),
|
| 153 |
+
("pause", re.compile(rf"^(pausa|pause|pare|para|stop)\b( a| o| the) {_MIDIA}$|^(pausa|pause)$|^(da|de|dar) pause$|^(pausa|pause) {_MIDIA}$")),
|
| 154 |
+
("play", re.compile(rf"^(toca|toque|continua|continue|retoma|retome|resume|play|unpause)\b( a| o| the)? {_MIDIA}$|^(play)$|^(da|de|dar) play$|"
|
| 155 |
+
r"^(volta|volte) a tocar$")),
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def acao_da_frase(pedido):
|
| 160 |
+
"""A ação de mídia que a frase pede, ou None. Só frases completas e sem objeto: "toca Legião Urbana" NÃO entra (é uma busca)."""
|
| 161 |
+
n = normalizar(pedido)
|
| 162 |
+
if len(n) > 80:
|
| 163 |
+
return None
|
| 164 |
+
for acao, regra in _ACOES:
|
| 165 |
+
if regra.search(n):
|
| 166 |
+
return acao
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def responder(pedido, idioma):
|
| 171 |
+
acao = acao_da_frase(pedido)
|
| 172 |
+
return agir(acao, idioma) if acao else None
|
app/tars_modos.py
CHANGED
|
@@ -1,50 +1,61 @@
|
|
| 1 |
-
"""openTARS 5.
|
| 2 |
|
|
|
|
|
|
|
| 3 |
Só biblioteca padrão: a janela pergunta aqui o que mostrar, e o núcleo pergunta os ajustes avançados.
|
| 4 |
-
A escolha fica em ~/.config/opentars/config.json ("
|
| 5 |
-
|
| 6 |
-
- Lite: conversa, microfone, anexos e o básico das configurações. Tudo no automático.
|
| 7 |
-
- Base: Lite + histórico, escolha de modelo, memória, rotinas, Probes e nuvem.
|
| 8 |
-
- Pro: Base + ajustes avançados e diagnóstico.
|
| 9 |
"""
|
| 10 |
|
| 11 |
import tars_i18n
|
| 12 |
import tars_primeiro_uso as pu
|
| 13 |
|
| 14 |
-
MODOS = ("
|
| 15 |
-
PADRAO_INSTALACAO_NOVA = "base"
|
| 16 |
-
PADRAO_ATUALIZACAO = "pro" # quem já usava a 5.0 não perde nada do que via
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
RECURSOS = {"
|
|
|
|
| 22 |
|
| 23 |
CONTEXTOS = (4096, 8192, 16384, 32768)
|
| 24 |
TEMPERATURA_MAXIMA = 1.5
|
| 25 |
LIMITE_INSTRUCOES = 1500
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
def modo_atual():
|
| 29 |
-
|
| 30 |
-
if m in MODOS:
|
| 31 |
-
return m
|
| 32 |
-
# sem escolha gravada: quem atualizou da 5.0 (primeiro uso já feito) fica no Pro; instalação nova, Base
|
| 33 |
-
return PADRAO_ATUALIZACAO if pu.primeiro_uso_concluido() else PADRAO_INSTALACAO_NOVA
|
| 34 |
|
| 35 |
|
| 36 |
def definir_modo(modo):
|
| 37 |
-
|
|
|
|
| 38 |
raise ValueError(f"modo desconhecido: {modo}")
|
| 39 |
-
|
| 40 |
|
| 41 |
|
| 42 |
def mostra(recurso, modo=None):
|
| 43 |
-
return recurso in RECURSOS[modo or modo_atual()]
|
| 44 |
|
| 45 |
|
| 46 |
# ------------------------------------------------------------
|
| 47 |
-
# Ajustes avançados (só valem
|
| 48 |
# ------------------------------------------------------------
|
| 49 |
|
| 50 |
def _limpar(bruto):
|
|
@@ -65,13 +76,13 @@ def _limpar(bruto):
|
|
| 65 |
|
| 66 |
|
| 67 |
def avancado_gravado():
|
| 68 |
-
"""O que está gravado, valha ou não agora (a tela
|
| 69 |
return _limpar(tars_i18n.ler_config().get("avancado"))
|
| 70 |
|
| 71 |
|
| 72 |
def avancado():
|
| 73 |
-
"""Os ajustes que valem agora: os gravados
|
| 74 |
-
return avancado_gravado() if
|
| 75 |
|
| 76 |
|
| 77 |
def salvar_avancado(**mudancas):
|
|
|
|
| 1 |
+
"""openTARS 5.3: um modo só (Base) e a chave "dev", que acrescenta os ajustes avançados e o diagnóstico.
|
| 2 |
|
| 3 |
+
Até a 5.2 havia Lite, Base e Pro; em 07/11 o Enzo decidiu: Base para todo mundo e "dev" nas Configurações.
|
| 4 |
+
Quem usava o Pro atualiza com o dev ligado; quem usava o Lite ou o Base fica no Base.
|
| 5 |
Só biblioteca padrão: a janela pergunta aqui o que mostrar, e o núcleo pergunta os ajustes avançados.
|
| 6 |
+
A escolha fica em ~/.config/opentars/config.json ("dev", e o "modo" antigo só para migrar).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import tars_i18n
|
| 10 |
import tars_primeiro_uso as pu
|
| 11 |
|
| 12 |
+
MODOS = ("base", "dev")
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
_BASE = {"conversa", "microfone", "anexos", "cfg.geral", "cfg.assistente", "cfg.voz", "cfg.atualizacoes", "cfg.sobre",
|
| 15 |
+
"historico", "seletor_modelo", "memoria", "cfg.memoria", "cfg.rotinas", "cfg.probes", "cfg.nuvem", "cfg.modelos", "cfg.gerenciar"}
|
| 16 |
+
_DEV = _BASE | {"cfg.avancado", "cfg.diagnostico"}
|
| 17 |
+
RECURSOS = {"base": _BASE, "dev": _DEV}
|
| 18 |
+
_ANTIGOS = {"lite": "base", "pro": "dev"}
|
| 19 |
|
| 20 |
CONTEXTOS = (4096, 8192, 16384, 32768)
|
| 21 |
TEMPERATURA_MAXIMA = 1.5
|
| 22 |
LIMITE_INSTRUCOES = 1500
|
| 23 |
|
| 24 |
|
| 25 |
+
def dev_ligado():
|
| 26 |
+
config = tars_i18n.ler_config()
|
| 27 |
+
if isinstance(config.get("dev"), bool):
|
| 28 |
+
return config["dev"]
|
| 29 |
+
antigo = config.get("modo")
|
| 30 |
+
if antigo in ("lite", "base"):
|
| 31 |
+
return False
|
| 32 |
+
if antigo == "pro":
|
| 33 |
+
return True
|
| 34 |
+
# sem escolha gravada: quem atualizou da 5.0 via tudo (o antigo Pro); instalação nova começa no Base
|
| 35 |
+
return pu.primeiro_uso_concluido()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def definir_dev(ligado):
|
| 39 |
+
tars_i18n.salvar_config(dev=bool(ligado))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
def modo_atual():
|
| 43 |
+
return "dev" if dev_ligado() else "base"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def definir_modo(modo):
|
| 47 |
+
"""Compatibilidade: "pro"/"dev" ligam o dev; "lite"/"base" desligam."""
|
| 48 |
+
if modo not in MODOS and modo not in _ANTIGOS:
|
| 49 |
raise ValueError(f"modo desconhecido: {modo}")
|
| 50 |
+
definir_dev(_ANTIGOS.get(modo, modo) == "dev")
|
| 51 |
|
| 52 |
|
| 53 |
def mostra(recurso, modo=None):
|
| 54 |
+
return recurso in RECURSOS[_ANTIGOS.get(modo, modo) or modo_atual()]
|
| 55 |
|
| 56 |
|
| 57 |
# ------------------------------------------------------------
|
| 58 |
+
# Ajustes avançados (só valem com o dev ligado; no Base tudo fica no automático)
|
| 59 |
# ------------------------------------------------------------
|
| 60 |
|
| 61 |
def _limpar(bruto):
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
def avancado_gravado():
|
| 79 |
+
"""O que está gravado, valha ou não agora (a tela dos ajustes avançados mostra isso)."""
|
| 80 |
return _limpar(tars_i18n.ler_config().get("avancado"))
|
| 81 |
|
| 82 |
|
| 83 |
def avancado():
|
| 84 |
+
"""Os ajustes que valem agora: os gravados, com o dev ligado; os padrões, no Base."""
|
| 85 |
+
return avancado_gravado() if dev_ligado() else _limpar({})
|
| 86 |
|
| 87 |
|
| 88 |
def salvar_avancado(**mudancas):
|
app/tars_motor.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A porta do motor de modelos, como o tars_plataforma é a porta do sistema. Hoje o motor é o Ollama; a 5.3 troca pelo llama.cpp
|
| 2 |
+
(PLANO_5.3.md, F9). Cada rota abaixo é uma operação que o motor novo precisa responder, e nenhum endereço da API mora fora daqui:
|
| 3 |
+
tests/test_motor.py confere."""
|
| 4 |
+
|
| 5 |
+
ROTAS = {
|
| 6 |
+
"versao": "/api/version", # está de pé?
|
| 7 |
+
"modelos": "/api/tags", # o que está instalado
|
| 8 |
+
"detalhes": "/api/show", # capacidades, parâmetros e quantização de um modelo
|
| 9 |
+
"rodando": "/api/ps", # o que está na memória
|
| 10 |
+
"conversa": "/api/chat", # conversa com ferramentas, em streaming ou não
|
| 11 |
+
"gerar": "/api/generate", # carregar e descarregar da memória (keep_alive), e pergunta simples
|
| 12 |
+
"baixar": "/api/pull",
|
| 13 |
+
"apagar": "/api/delete",
|
| 14 |
+
"conta": "/api/me", # conta do ollama.com (nuvem): não existe no llama.cpp
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def url(rota, host):
|
| 19 |
+
return f"{str(host).rstrip('/')}{ROTAS[rota]}"
|
app/tars_motor_local.py
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""O motor do openTARS 5.3 (F9 do PLANO_5.3.md): llama.cpp por dentro, a API do Ollama por fora.
|
| 2 |
+
|
| 3 |
+
Decidido pelo Enzo em 07/11: llama.cpp nos três sistemas, e a parte de visão (mmproj) só quando chega uma imagem. O app continua
|
| 4 |
+
falando "Ollama" (13 chamadas, tars_motor.ROTAS): este processo responde nessas rotas e roda um `llama-server` por modelo em uso.
|
| 5 |
+
|
| 6 |
+
- Modelos Gargantua baixados do Hugging Face (OpenTARS/...) com SHA-256 conferido, na pasta de dados do app.
|
| 7 |
+
- `llama-server` da Release fixa do ggml-org (VERSAO_LLAMA), conferido pelo hash: Vulkan no Linux e no Windows (com reserva em CPU
|
| 8 |
+
se a máquina não tiver Vulkan), Metal no macOS.
|
| 9 |
+
- Uma instância por modelo; a de visão só sobe quando o pedido traz imagem. Cada uma sai depois do keep_alive parado (padrão 5 min,
|
| 10 |
+
-1 = fica, 0 = sai já). O processo do motor fica aberto (como o serviço do Ollama), só com uns MB de Python.
|
| 11 |
+
|
| 12 |
+
python3 tars_motor_local.py --servir (o app sobe sozinho quando precisa: garantir())
|
| 13 |
+
"""
|
| 14 |
+
import hashlib
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import platform
|
| 18 |
+
import shutil
|
| 19 |
+
import socket
|
| 20 |
+
import subprocess
|
| 21 |
+
import sys
|
| 22 |
+
import tarfile
|
| 23 |
+
import threading
|
| 24 |
+
import time
|
| 25 |
+
import urllib.error
|
| 26 |
+
import urllib.request
|
| 27 |
+
import zipfile
|
| 28 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import tars_plataforma as plataforma
|
| 32 |
+
|
| 33 |
+
PORTA = int(os.environ.get("TARS_MOTOR_PORTA") or 11435)
|
| 34 |
+
URL = f"http://127.0.0.1:{PORTA}"
|
| 35 |
+
VERSAO_LLAMA = "b12032" # 01/11/2026: a mesma usada no Space; versão fixa, nada de "a mais nova do dia"
|
| 36 |
+
_GH = f"https://github.com/ggml-org/llama.cpp/releases/download/{VERSAO_LLAMA}/"
|
| 37 |
+
BINARIOS = { # (sistema, máquina, aceleração): (arquivo, sha256)
|
| 38 |
+
("linux", "x86_64", "vulkan"): ("llama-b12032-bin-ubuntu-vulkan-x64.tar.gz", "f75e7107f9450034c5467492c41e9ed91816e2d815fc4b802c6bd58de7d5b126"),
|
| 39 |
+
("linux", "x86_64", "cpu"): ("llama-b12032-bin-ubuntu-x64.tar.gz", "9f10432ca3f66ef003a7a540aaffb9da0acadaefd05404521b9ab7a2436af304"),
|
| 40 |
+
("windows", "x86_64", "vulkan"): ("llama-b12032-bin-win-vulkan-x64.zip", "19a8bbf269fa683ae7bc04f886c9c2a93e0655c2606f37d2b60069bf9e8bcc32"),
|
| 41 |
+
("windows", "x86_64", "cpu"): ("llama-b12032-bin-win-cpu-x64.zip", "5296f5d18bb7d422c7c26f4bb9bc796d9d4f41e1312a8c296cff475bdc60d0d4"),
|
| 42 |
+
("macos", "arm64", "metal"): ("llama-b12032-bin-macos-arm64.tar.gz", "00468fe833eb000b78a748122f5921b6c7b07dc82331a8d0c68196c69cb26382"),
|
| 43 |
+
("macos", "x86_64", "cpu"): ("llama-b12032-bin-macos-x64.tar.gz", "291d99f3b6f45f67f73afe5381540f29928840a1a14d9c19d6595bd649a035fc"),
|
| 44 |
+
}
|
| 45 |
+
_HF = "https://huggingface.co/OpenTARS/{repo}/resolve/main/{arquivo}"
|
| 46 |
+
_PARAMETROS_QWEN = "top_k 20\ntop_p 0.8\nrepeat_penalty 1.05\ntemperature 0.4"
|
| 47 |
+
_PARAMETROS_GEMMA = "top_k 64\ntop_p 0.95\ntemperature 0.4"
|
| 48 |
+
CATALOGO = { # a ficha que o Ollama dava para cada modelo (copiada em 07/11), mais os arquivos do Hugging Face
|
| 49 |
+
"ProjectOpenTARS/Gargantua-2.5:latest": { # 13/11: Gemma 4 E4B, treino completo (1.949 passos)
|
| 50 |
+
"repo": "Gargantua-2.5", "gguf": ("gargantua-2.5-gemma4-e4b-Q4_K_M.gguf", "6b9e28f8c5eb8361796d0ecbe0d6a7bc3338f942820afcad388c5beeead795e1", 5302274208),
|
| 51 |
+
"mmproj": ("mmproj-gargantua-2.5-gemma4-e4b-F16.gguf", "c4347b30c7d9d176bb264fc6a14b81e44dde7266b9dd035ea60a91170d39775f", 990372160),
|
| 52 |
+
"capabilities": ["completion", "vision", "audio", "tools", "thinking"], "family": "gemma4", "parameter_size": "7.5B", "quantization_level": "Q4_K_M",
|
| 53 |
+
"parameters": _PARAMETROS_GEMMA},
|
| 54 |
+
"ProjectOpenTARS/Gargantua-2.5:lite": { # 13/11: Gemma 4 E2B (Lite Gen 3), primeira época (a segunda inventou mais: 11 contra 4 de 48; decisão do Enzo)
|
| 55 |
+
"repo": "Gargantua-2.5-Lite", "gguf": ("gargantua-2.5-lite-gemma4-e2b-Q4_K_M.gguf", "08b61b2a5e860033565cc8a057c03331f9d8a1f1465e3bd45a0f55117bb26c29", 3416120096),
|
| 56 |
+
"mmproj": ("mmproj-gargantua-2.5-lite-gemma4-e2b-F16.gguf", "7024964e8736cfe91cee244670315bc6aa8582dcdfb7eb65e4b460df7681546a", 985653568),
|
| 57 |
+
"capabilities": ["completion", "vision", "audio", "tools", "thinking"], "family": "gemma4", "parameter_size": "4.6B", "quantization_level": "Q4_K_M",
|
| 58 |
+
"parameters": _PARAMETROS_GEMMA},
|
| 59 |
+
"ProjectOpenTARS/Gargantua-Pro:pro": { # 11/11: Gemma 4 12B (Pro Gen 1, passo 300 de 368); o projetor tem de ser F32 (em F16 a placa corrompe)
|
| 60 |
+
"repo": "Gargantua-Pro", "gguf": ("gargantua-pro-gemma4-12b-Q4_K_M.gguf", "add484a229114d2cf6d7716f8264f830dd85a732591fbbc20439c3bebc943e0b", 7381383104),
|
| 61 |
+
"mmproj": ("mmproj-gargantua-pro-gemma4-12b-F32.gguf", "f35db6bf889c96f70f75350b371d2d0bd99d1bd62dea88c89594d245719d0267", 209521600),
|
| 62 |
+
"capabilities": ["completion", "vision", "tools", "thinking"], "family": "gemma4", "parameter_size": "11.95B", "quantization_level": "Q4_K_M",
|
| 63 |
+
"parameters": _PARAMETROS_GEMMA},
|
| 64 |
+
"ProjectOpenTARS/Gargantua-2:latest": {
|
| 65 |
+
"repo": "Gargantua-2", "gguf": ("gargantua-1.2-qwen3.5-2b-Q4_0.gguf", "3f2d450ec96be581d4b05f34eab703d1bd78e9f3e88a69f51e5efb6c5c61a86c", 1202486848),
|
| 66 |
+
"mmproj": ("mmproj-gargantua-qwen3.5-2b-Q8_0.gguf", "3156ce8710040751b6affc151eb66fe225dbe369a4ae51cd6298b1ae74087790", 364663840),
|
| 67 |
+
"capabilities": ["completion", "vision", "tools", "thinking"], "family": "qwen35", "parameter_size": "1.9B", "quantization_level": "Q4_0",
|
| 68 |
+
"parameters": _PARAMETROS_QWEN},
|
| 69 |
+
"ProjectOpenTARS/Gargantua-2:lite": {
|
| 70 |
+
"repo": "Gargantua-2-Lite", "gguf": ("gargantua-1.2-lite-qwen3.5-0.8b-Q4_0.gguf", "8736bc95729f047dfcf265e8fdaa8ed611bf9cf30c8d06e79b8e4f0d1cbbf29a", 501452352),
|
| 71 |
+
"mmproj": ("mmproj-gargantua-qwen3.5-0.8b-Q8_0.gguf", "1251de28a9e9c147e81a589d64e67470a5e8aa66a99834bb6902283a4d210519", 115923136),
|
| 72 |
+
"capabilities": ["completion", "vision", "tools", "thinking"], "family": "qwen35", "parameter_size": "752.39M", "quantization_level": "Q4_0",
|
| 73 |
+
"parameters": _PARAMETROS_QWEN},
|
| 74 |
+
"ProjectOpenTARS/Gargantua-Code-Gen2:code": {
|
| 75 |
+
"repo": "Gargantua-Code-Gen2", "gguf": ("gargantua-1.2-code-qwen2.5-coder-7b-Q4_K_M.gguf", "4c17360dcb36a3c0932f600df0eb6d7a3859e3ee7406fd4bbfb0f7c526904456", 4683073536),
|
| 76 |
+
"mmproj": None, "capabilities": ["completion", "tools", "insert"], "family": "qwen2", "parameter_size": "7.6B", "quantization_level": "Q4_K_M",
|
| 77 |
+
"parameters": _PARAMETROS_QWEN},
|
| 78 |
+
}
|
| 79 |
+
KEEP_ALIVE_PADRAO = 300
|
| 80 |
+
OLLAMA = os.environ.get("TARS_OLLAMA_REAL") or "http://127.0.0.1:11434" # modelos que não são do catálogo ("Mais modelos") seguem para ele
|
| 81 |
+
CONTEXTO = 8192
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------- arquivos
|
| 85 |
+
|
| 86 |
+
def pasta():
|
| 87 |
+
return plataforma.pastas()["dados"] / "motor"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def pasta_modelos():
|
| 91 |
+
return pasta() / "modelos"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def sistema_e_maquina():
|
| 95 |
+
maquina = platform.machine().lower()
|
| 96 |
+
maquina = {"amd64": "x86_64", "aarch64": "arm64"}.get(maquina, maquina)
|
| 97 |
+
return plataforma.sistema(), maquina
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def tem_vulkan():
|
| 101 |
+
s, _ = sistema_e_maquina()
|
| 102 |
+
if s == "windows":
|
| 103 |
+
return Path(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "vulkan-1.dll").exists()
|
| 104 |
+
if s == "linux":
|
| 105 |
+
try:
|
| 106 |
+
return "libvulkan.so.1" in subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=5).stdout
|
| 107 |
+
except (OSError, subprocess.SubprocessError):
|
| 108 |
+
return any(Path(p, "libvulkan.so.1").exists() for p in ("/usr/lib/x86_64-linux-gnu", "/usr/lib64", "/usr/lib"))
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def variantes():
|
| 113 |
+
"""As acelerações a tentar, da melhor para a reserva."""
|
| 114 |
+
s, m = sistema_e_maquina()
|
| 115 |
+
if s == "macos":
|
| 116 |
+
return ["metal"] if m == "arm64" else ["cpu"]
|
| 117 |
+
return (["vulkan"] if tem_vulkan() else []) + ["cpu"]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def binario():
|
| 121 |
+
"""O llama-server instalado (o da melhor variante que já foi baixada), ou None."""
|
| 122 |
+
s, m = sistema_e_maquina()
|
| 123 |
+
nome = "llama-server.exe" if s == "windows" else "llama-server"
|
| 124 |
+
for v in variantes():
|
| 125 |
+
raiz = pasta() / VERSAO_LLAMA / v
|
| 126 |
+
if raiz.exists():
|
| 127 |
+
achado = next(iter(sorted(raiz.rglob(nome))), None)
|
| 128 |
+
if achado:
|
| 129 |
+
return achado
|
| 130 |
+
return None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _sha256(caminho, algoritmo="sha256"):
|
| 134 |
+
h = hashlib.new(algoritmo)
|
| 135 |
+
with open(caminho, "rb") as f:
|
| 136 |
+
for bloco in iter(lambda: f.read(1 << 20), b""):
|
| 137 |
+
h.update(bloco)
|
| 138 |
+
return h.hexdigest()
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def baixar(url, destino, sha, ao_progresso=None, tentativas=3, algoritmo="sha256"):
|
| 142 |
+
"""Baixa com retomada (.parcial), confere o hash (SHA-256; `algoritmo` troca, a imagem do Debian usa SHA-512) e só então põe
|
| 143 |
+
no lugar. Levanta ValueError se o hash não bater."""
|
| 144 |
+
for tentativa in range(tentativas): # a conexão que trava no meio retoma do .parcial (visto em 07/11 no GitHub)
|
| 145 |
+
try:
|
| 146 |
+
return _baixar(url, destino, sha, ao_progresso, algoritmo)
|
| 147 |
+
except (TimeoutError, ConnectionError, urllib.error.URLError):
|
| 148 |
+
if tentativa == tentativas - 1:
|
| 149 |
+
raise
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _baixar(url, destino, sha, ao_progresso, algoritmo="sha256"):
|
| 153 |
+
destino = Path(destino)
|
| 154 |
+
destino.parent.mkdir(parents=True, exist_ok=True)
|
| 155 |
+
parcial = destino.with_name(destino.name + ".parcial")
|
| 156 |
+
feito = parcial.stat().st_size if parcial.exists() else 0
|
| 157 |
+
pedido = urllib.request.Request(url, headers={"User-Agent": "openTARS", **({"Range": f"bytes={feito}-"} if feito else {})})
|
| 158 |
+
with urllib.request.urlopen(pedido, timeout=60) as r:
|
| 159 |
+
if feito and r.status != 206:
|
| 160 |
+
feito = 0 # o servidor não retomou: começa de novo
|
| 161 |
+
total = feito + int(r.headers.get("Content-Length") or 0)
|
| 162 |
+
with open(parcial, "ab" if feito else "wb") as f:
|
| 163 |
+
for bloco in iter(lambda: r.read(1 << 16), b""):
|
| 164 |
+
f.write(bloco)
|
| 165 |
+
feito += len(bloco)
|
| 166 |
+
if ao_progresso:
|
| 167 |
+
ao_progresso(feito, total)
|
| 168 |
+
if _sha256(parcial, algoritmo) != sha:
|
| 169 |
+
parcial.unlink(missing_ok=True)
|
| 170 |
+
raise ValueError(f"{destino.name}: {algoritmo.upper()} check failed")
|
| 171 |
+
os.replace(parcial, destino)
|
| 172 |
+
return destino
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def instalar_binario(ao_progresso=None):
|
| 176 |
+
"""Baixa e extrai o llama-server desta máquina (a melhor variante; a de CPU se a outra não existir aqui)."""
|
| 177 |
+
s, m = sistema_e_maquina()
|
| 178 |
+
for v in variantes():
|
| 179 |
+
chave = (s, m, v)
|
| 180 |
+
if chave not in BINARIOS:
|
| 181 |
+
continue
|
| 182 |
+
arquivo, sha = BINARIOS[chave]
|
| 183 |
+
raiz = pasta() / VERSAO_LLAMA / v
|
| 184 |
+
pacote = baixar(_GH + arquivo, pasta() / "baixados" / arquivo, sha, ao_progresso)
|
| 185 |
+
temporario = raiz.with_name(raiz.name + ".novo")
|
| 186 |
+
shutil.rmtree(temporario, ignore_errors=True)
|
| 187 |
+
temporario.mkdir(parents=True)
|
| 188 |
+
if arquivo.endswith(".zip"):
|
| 189 |
+
with zipfile.ZipFile(pacote) as z:
|
| 190 |
+
z.extractall(temporario)
|
| 191 |
+
else:
|
| 192 |
+
with tarfile.open(pacote) as t:
|
| 193 |
+
t.extractall(temporario, **({"filter": "data"} if hasattr(tarfile, "data_filter") else {}))
|
| 194 |
+
for executavel in temporario.rglob("llama-server*"):
|
| 195 |
+
executavel.chmod(0o755)
|
| 196 |
+
shutil.rmtree(raiz, ignore_errors=True)
|
| 197 |
+
os.replace(temporario, raiz)
|
| 198 |
+
pacote.unlink(missing_ok=True)
|
| 199 |
+
return binario()
|
| 200 |
+
raise ValueError(f"no llama.cpp build for {s} {m}")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _blob_do_ollama(sha):
|
| 204 |
+
"""O arquivo do Ollama com esse SHA-256 (o mesmo GGUF que publicamos no Hugging Face), se esta máquina tiver."""
|
| 205 |
+
pastas = [os.environ.get("OLLAMA_MODELS"), Path.home() / ".ollama" / "models", "/usr/share/ollama/.ollama/models", "/var/lib/ollama/models"]
|
| 206 |
+
for raiz in filter(None, pastas):
|
| 207 |
+
blob = Path(raiz) / "blobs" / f"sha256-{sha}"
|
| 208 |
+
if blob.is_file() and os.access(blob, os.R_OK):
|
| 209 |
+
return blob
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def sha_do_arquivo(destino):
|
| 214 |
+
"""SHA-256 de um GGUF já na pasta: lido da marca ao lado (gravada ao pôr o arquivo), ou calculado uma vez. O nome do arquivo
|
| 215 |
+
não muda entre versões do modelo; sem a marca, o modelo velho passava por novo para sempre (revisão de 13/11)."""
|
| 216 |
+
marca = Path(destino).with_name(Path(destino).name + ".sha256")
|
| 217 |
+
try:
|
| 218 |
+
return marca.read_text().strip()
|
| 219 |
+
except OSError:
|
| 220 |
+
sha = _sha256(destino)
|
| 221 |
+
marca.write_text(sha)
|
| 222 |
+
return sha
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _atual(destino, sha):
|
| 226 |
+
return destino.exists() and sha_do_arquivo(destino) == sha
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def obter_arquivo(repo, arquivo, sha, ao_progresso=None):
|
| 230 |
+
"""Põe um GGUF do catálogo na pasta: copiado do Ollama se ele já tem o mesmo arquivo, senão baixado do Hugging Face.
|
| 231 |
+
Um arquivo de mesmo nome com outro SHA-256 (versão anterior do modelo) é trocado."""
|
| 232 |
+
destino = pasta_modelos() / arquivo
|
| 233 |
+
if _atual(destino, sha):
|
| 234 |
+
return destino
|
| 235 |
+
blob = _blob_do_ollama(sha)
|
| 236 |
+
if not blob:
|
| 237 |
+
baixar(_HF.format(repo=repo, arquivo=arquivo), destino, sha, ao_progresso)
|
| 238 |
+
else:
|
| 239 |
+
destino.parent.mkdir(parents=True, exist_ok=True)
|
| 240 |
+
temporario = destino.with_name(destino.name + ".parcial")
|
| 241 |
+
shutil.copyfile(blob, temporario)
|
| 242 |
+
if _sha256(temporario) != sha:
|
| 243 |
+
temporario.unlink(missing_ok=True)
|
| 244 |
+
raise ValueError(f"{arquivo}: SHA-256 check failed")
|
| 245 |
+
os.replace(temporario, destino)
|
| 246 |
+
destino.with_name(destino.name + ".sha256").write_text(sha)
|
| 247 |
+
return destino
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def preparar(nomes, ao_progresso=None):
|
| 251 |
+
"""Instala o llama-server e os modelos pedidos. Levanta OSError/ValueError."""
|
| 252 |
+
if binario() is None:
|
| 253 |
+
instalar_binario(ao_progresso)
|
| 254 |
+
for nome in nomes:
|
| 255 |
+
ficha = CATALOGO[_nome(nome)]
|
| 256 |
+
for arquivo, sha, _ in [ficha["gguf"]] + ([ficha["mmproj"]] if ficha.get("mmproj") else []):
|
| 257 |
+
obter_arquivo(ficha["repo"], arquivo, sha, ao_progresso)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _nome(nome):
|
| 261 |
+
""""ProjectOpenTARS/Gargantua-2" é o mesmo que ":latest", como no Ollama."""
|
| 262 |
+
return nome if not nome or ":" in nome.split("/")[-1] else nome + ":latest"
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def instalado(nome):
|
| 266 |
+
nome = _nome(nome)
|
| 267 |
+
ficha = CATALOGO.get(nome)
|
| 268 |
+
return bool(ficha) and (pasta_modelos() / ficha["gguf"][0]).exists()
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def instalados():
|
| 272 |
+
return [n for n in CATALOGO if instalado(n)]
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def escolhido():
|
| 276 |
+
""""llamacpp" (padrão) ou "ollama". A variável TARS_MOTOR vale mais que a config (chave "motor", Configurações > Avançado, de dev)."""
|
| 277 |
+
v = (os.environ.get("TARS_MOTOR") or "").strip().lower()
|
| 278 |
+
if v not in ("ollama", "llamacpp"):
|
| 279 |
+
try:
|
| 280 |
+
import tars_i18n
|
| 281 |
+
v = str(tars_i18n.ler_config().get("motor", "")).strip().lower()
|
| 282 |
+
except Exception:
|
| 283 |
+
v = ""
|
| 284 |
+
return "ollama" if v == "ollama" else "llamacpp"
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def pronto():
|
| 288 |
+
"""O motor pode servir agora: tem o llama-server e ao menos um modelo."""
|
| 289 |
+
return binario() is not None and bool(instalados())
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
# ---------------------------------------------------------------- instâncias do llama-server
|
| 293 |
+
|
| 294 |
+
def _porta_livre():
|
| 295 |
+
with socket.socket() as s:
|
| 296 |
+
s.bind(("127.0.0.1", 0))
|
| 297 |
+
return s.getsockname()[1]
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _pedir(url, corpo=None, tempo=900):
|
| 301 |
+
dados = json.dumps(corpo).encode() if corpo is not None else None
|
| 302 |
+
return urllib.request.urlopen(urllib.request.Request(url, dados, {"Content-Type": "application/json"}), timeout=tempo)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
class Instancia:
|
| 306 |
+
def __init__(self, nome, visao):
|
| 307 |
+
ficha = CATALOGO[nome]
|
| 308 |
+
self.nome, self.visao, self.porta = nome, visao, _porta_livre()
|
| 309 |
+
self.ultimo, self.keep_alive = time.time(), KEEP_ALIVE_PADRAO
|
| 310 |
+
comando = [str(binario()), "-m", str(pasta_modelos() / ficha["gguf"][0]), "--host", "127.0.0.1", "--port", str(self.porta),
|
| 311 |
+
"-c", str(CONTEXTO), "--jinja", "--parallel", "1", "--no-webui", "-ngl", "99"]
|
| 312 |
+
if visao and ficha.get("mmproj"):
|
| 313 |
+
comando += ["--mmproj", str(pasta_modelos() / ficha["mmproj"][0])]
|
| 314 |
+
registro = open(pasta() / "llama-server.log", "ab")
|
| 315 |
+
extra = {"creationflags": 0x08000000} if plataforma.sistema() == "windows" else {"start_new_session": True} # sem janela preta no Windows
|
| 316 |
+
self.processo = subprocess.Popen(comando, stdin=subprocess.DEVNULL, stdout=registro, stderr=registro, **extra)
|
| 317 |
+
limite = time.time() + 300
|
| 318 |
+
while time.time() < limite:
|
| 319 |
+
if self.processo.poll() is not None:
|
| 320 |
+
raise RuntimeError(f"llama-server stopped with code {self.processo.returncode} (see {pasta() / 'llama-server.log'})")
|
| 321 |
+
try:
|
| 322 |
+
with _pedir(f"http://127.0.0.1:{self.porta}/health", tempo=2) as r:
|
| 323 |
+
if json.loads(r.read()).get("status") == "ok":
|
| 324 |
+
return
|
| 325 |
+
except (OSError, ValueError):
|
| 326 |
+
time.sleep(0.3)
|
| 327 |
+
self.parar()
|
| 328 |
+
raise RuntimeError("llama-server took too long to start")
|
| 329 |
+
|
| 330 |
+
def parar(self):
|
| 331 |
+
if self.processo.poll() is None:
|
| 332 |
+
self.processo.terminate()
|
| 333 |
+
try:
|
| 334 |
+
self.processo.wait(10)
|
| 335 |
+
except subprocess.TimeoutExpired:
|
| 336 |
+
self.processo.kill()
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
class Motor:
|
| 340 |
+
def __init__(self):
|
| 341 |
+
self.instancias, self.trava, self.ultimo_pedido = {}, threading.Lock(), time.time()
|
| 342 |
+
|
| 343 |
+
def instancia(self, nome, visao=False):
|
| 344 |
+
if nome not in CATALOGO or not instalado(nome):
|
| 345 |
+
raise KeyError(nome)
|
| 346 |
+
visao = visao and bool(CATALOGO[nome].get("mmproj"))
|
| 347 |
+
with self.trava:
|
| 348 |
+
chave = (nome, visao)
|
| 349 |
+
atual = self.instancias.get(chave)
|
| 350 |
+
if atual is None or atual.processo.poll() is not None:
|
| 351 |
+
atual = self.instancias[chave] = Instancia(nome, visao)
|
| 352 |
+
atual.ultimo = time.time()
|
| 353 |
+
return atual
|
| 354 |
+
|
| 355 |
+
def descarregar(self, nome):
|
| 356 |
+
with self.trava:
|
| 357 |
+
for chave in [c for c in self.instancias if c[0] == nome]:
|
| 358 |
+
self.instancias.pop(chave).parar()
|
| 359 |
+
|
| 360 |
+
def faxina(self):
|
| 361 |
+
"""Tira da memória o que ficou parado além do keep_alive."""
|
| 362 |
+
agora = time.time()
|
| 363 |
+
with self.trava:
|
| 364 |
+
for chave, inst in list(self.instancias.items()):
|
| 365 |
+
if inst.processo.poll() is not None or (inst.keep_alive >= 0 and agora - inst.ultimo > inst.keep_alive):
|
| 366 |
+
self.instancias.pop(chave).parar()
|
| 367 |
+
|
| 368 |
+
def parar_tudo(self):
|
| 369 |
+
with self.trava:
|
| 370 |
+
for inst in self.instancias.values():
|
| 371 |
+
inst.parar()
|
| 372 |
+
self.instancias.clear()
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _keep_alive(valor):
|
| 376 |
+
if valor in (None, ""):
|
| 377 |
+
return KEEP_ALIVE_PADRAO
|
| 378 |
+
if isinstance(valor, (int, float)):
|
| 379 |
+
return float(valor)
|
| 380 |
+
texto = str(valor).strip()
|
| 381 |
+
unidade = {"s": 1, "m": 60, "h": 3600}.get(texto[-1:], 1)
|
| 382 |
+
try:
|
| 383 |
+
return float(texto[:-1] if texto[-1:] in "smh" else texto) * unidade
|
| 384 |
+
except ValueError:
|
| 385 |
+
return KEEP_ALIVE_PADRAO
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ---------------------------------------------------------------- tradução Ollama <-> OpenAI (llama-server)
|
| 389 |
+
|
| 390 |
+
def _parametros(nome):
|
| 391 |
+
p = {}
|
| 392 |
+
for linha in CATALOGO[nome]["parameters"].splitlines():
|
| 393 |
+
partes = linha.split()
|
| 394 |
+
if len(partes) == 2:
|
| 395 |
+
try:
|
| 396 |
+
p[partes[0]] = float(partes[1])
|
| 397 |
+
except ValueError:
|
| 398 |
+
pass
|
| 399 |
+
return p
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def para_openai(corpo, nome):
|
| 403 |
+
mensagens, pendentes, numero = [], [], 0
|
| 404 |
+
for m in corpo.get("messages", []):
|
| 405 |
+
saida = {"role": m["role"], "content": m.get("content") or ""}
|
| 406 |
+
if m.get("images") or m.get("audio"): # "audio": WAV em base64, campo nosso (o Ollama não tem); o Gemma 4 ouve pelo projetor
|
| 407 |
+
saida["content"] = [{"type": "text", "text": saida["content"]}] + [
|
| 408 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{i}"}} for i in m.get("images") or []] + [
|
| 409 |
+
{"type": "input_audio", "input_audio": {"data": a, "format": "wav"}} for a in m.get("audio") or []]
|
| 410 |
+
if m.get("tool_calls"):
|
| 411 |
+
saida["tool_calls"] = []
|
| 412 |
+
for chamada in m["tool_calls"]:
|
| 413 |
+
numero += 1
|
| 414 |
+
f = chamada.get("function", {})
|
| 415 |
+
argumentos = f.get("arguments")
|
| 416 |
+
saida["tool_calls"].append({"id": f"call_{numero}", "type": "function", "function": {
|
| 417 |
+
"name": f.get("name"), "arguments": argumentos if isinstance(argumentos, str) else json.dumps(argumentos or {}, ensure_ascii=False)}})
|
| 418 |
+
pendentes.append(f"call_{numero}")
|
| 419 |
+
if m["role"] == "tool":
|
| 420 |
+
saida["tool_call_id"] = pendentes.pop(0) if pendentes else "call_0"
|
| 421 |
+
if m.get("tool_name") or m.get("name"):
|
| 422 |
+
saida["name"] = m.get("tool_name") or m.get("name")
|
| 423 |
+
mensagens.append(saida)
|
| 424 |
+
opcoes = {**_parametros(nome), **(corpo.get("options") or {})}
|
| 425 |
+
saida = {"messages": mensagens, "stream": bool(corpo.get("stream", True)), "cache_prompt": True,
|
| 426 |
+
"temperature": opcoes.get("temperature", 0.4), "top_p": opcoes.get("top_p", 0.8), "top_k": opcoes.get("top_k", 20),
|
| 427 |
+
"repeat_penalty": opcoes.get("repeat_penalty", 1.05), "chat_template_kwargs": {"enable_thinking": bool(corpo.get("think", False))}}
|
| 428 |
+
if opcoes.get("num_predict"):
|
| 429 |
+
saida["max_tokens"] = int(opcoes["num_predict"])
|
| 430 |
+
if corpo.get("tools"):
|
| 431 |
+
saida["tools"] = corpo["tools"]
|
| 432 |
+
if corpo.get("logprobs") and not (corpo.get("tools") and saida["stream"]):
|
| 433 |
+
saida["logprobs"] = True # shortcut: o llama-server recusa logprobs com ferramentas em streaming; aí a conferência de confiança fica sem sinal
|
| 434 |
+
if corpo.get("format") == "json":
|
| 435 |
+
saida["response_format"] = {"type": "json_object"}
|
| 436 |
+
elif isinstance(corpo.get("format"), dict):
|
| 437 |
+
saida["response_format"] = {"type": "json_schema", "json_schema": {"schema": corpo["format"]}}
|
| 438 |
+
return saida
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _chamadas_ollama(chamadas):
|
| 442 |
+
saida = []
|
| 443 |
+
for chamada in chamadas:
|
| 444 |
+
f = chamada.get("function", {})
|
| 445 |
+
argumentos = f.get("arguments")
|
| 446 |
+
try:
|
| 447 |
+
argumentos = json.loads(argumentos) if isinstance(argumentos, str) else argumentos
|
| 448 |
+
except ValueError:
|
| 449 |
+
argumentos = {}
|
| 450 |
+
saida.append({"function": {"name": f.get("name"), "arguments": argumentos or {}}})
|
| 451 |
+
return saida
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def _final(nome, inicio, mensagem, motivo, logprobs=None):
|
| 455 |
+
saida = {"model": nome, "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "message": mensagem, "done": True,
|
| 456 |
+
"done_reason": "length" if motivo == "length" else "stop", "total_duration": int((time.time() - inicio) * 1e9)}
|
| 457 |
+
if logprobs:
|
| 458 |
+
saida["logprobs"] = logprobs
|
| 459 |
+
return saida
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def _tem_imagem(corpo):
|
| 463 |
+
"""Imagem ou áudio no pedido: o llama-server precisa do projetor (mmproj), que só carrega quando precisa."""
|
| 464 |
+
return any(m.get("images") or m.get("audio") for m in corpo.get("messages", []))
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
# ---------------------------------------------------------------- o servidor com cara de Ollama
|
| 468 |
+
|
| 469 |
+
def _do_ollama(caminho):
|
| 470 |
+
"""Os modelos do Ollama da pessoa, menos os que o motor já tem (ele serve esses). Lista vazia se não há Ollama."""
|
| 471 |
+
try:
|
| 472 |
+
with urllib.request.urlopen(OLLAMA + caminho, timeout=1.5) as r:
|
| 473 |
+
return [m for m in json.loads(r.read()).get("models", []) if not instalado(m.get("name"))]
|
| 474 |
+
except (OSError, ValueError):
|
| 475 |
+
return []
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _ficha_tags(nome):
|
| 479 |
+
ficha = CATALOGO[nome]
|
| 480 |
+
caminho = pasta_modelos() / ficha["gguf"][0]
|
| 481 |
+
tamanho = ficha["gguf"][2] + (ficha["mmproj"][2] if ficha.get("mmproj") else 0)
|
| 482 |
+
detalhes = {"parent_model": ficha["gguf"][0], "format": "gguf", "family": ficha["family"], "families": [ficha["family"]],
|
| 483 |
+
"parameter_size": ficha["parameter_size"], "quantization_level": ficha["quantization_level"]}
|
| 484 |
+
modificado = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(caminho.stat().st_mtime)) if caminho.exists() else ""
|
| 485 |
+
return {"name": nome, "model": nome, "modified_at": modificado, "size": tamanho, "digest": ficha["gguf"][1], "details": detalhes,
|
| 486 |
+
"capabilities": ficha["capabilities"]}
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def servir():
|
| 490 |
+
motor = Motor()
|
| 491 |
+
pasta().mkdir(parents=True, exist_ok=True)
|
| 492 |
+
|
| 493 |
+
class Pedido(BaseHTTPRequestHandler):
|
| 494 |
+
protocol_version = "HTTP/1.1"
|
| 495 |
+
|
| 496 |
+
def log_message(self, *a):
|
| 497 |
+
pass
|
| 498 |
+
|
| 499 |
+
def _json(self, dados, codigo=200):
|
| 500 |
+
corpo = json.dumps(dados, ensure_ascii=False).encode()
|
| 501 |
+
self.send_response(codigo)
|
| 502 |
+
self.send_header("Content-Type", "application/json")
|
| 503 |
+
self.send_header("Content-Length", str(len(corpo)))
|
| 504 |
+
self.end_headers()
|
| 505 |
+
self.wfile.write(corpo)
|
| 506 |
+
|
| 507 |
+
def _linhas(self):
|
| 508 |
+
self.send_response(200)
|
| 509 |
+
self.send_header("Content-Type", "application/x-ndjson")
|
| 510 |
+
self.send_header("Transfer-Encoding", "chunked")
|
| 511 |
+
self.end_headers()
|
| 512 |
+
|
| 513 |
+
def _linha(self, dados):
|
| 514 |
+
texto = (json.dumps(dados, ensure_ascii=False) + "\n").encode()
|
| 515 |
+
self.wfile.write(f"{len(texto):x}\r\n".encode() + texto + b"\r\n")
|
| 516 |
+
self.wfile.flush()
|
| 517 |
+
|
| 518 |
+
def _fim_linhas(self):
|
| 519 |
+
self.wfile.write(b"0\r\n\r\n")
|
| 520 |
+
self.wfile.flush()
|
| 521 |
+
|
| 522 |
+
def do_GET(self):
|
| 523 |
+
motor.ultimo_pedido = time.time()
|
| 524 |
+
if self.path == "/api/version":
|
| 525 |
+
return self._json({"version": "0.40.6"})
|
| 526 |
+
if self.path == "/api/tags":
|
| 527 |
+
return self._json({"models": [_ficha_tags(n) for n in instalados()] + _do_ollama("/api/tags")})
|
| 528 |
+
if self.path == "/api/ps":
|
| 529 |
+
with motor.trava:
|
| 530 |
+
vivos = {c[0] for c in motor.instancias}
|
| 531 |
+
return self._json({"models": [dict(_ficha_tags(n), size_vram=0, expires_at="") for n in vivos] + _do_ollama("/api/ps")})
|
| 532 |
+
if self.path in ("/", "/api"):
|
| 533 |
+
return self._json({"status": "openTARS motor (llama.cpp)"})
|
| 534 |
+
self._json({"error": "not found"}, 404)
|
| 535 |
+
|
| 536 |
+
def do_HEAD(self):
|
| 537 |
+
self.send_response(200)
|
| 538 |
+
self.send_header("Content-Length", "0")
|
| 539 |
+
self.end_headers()
|
| 540 |
+
|
| 541 |
+
def _repassar(self, corpo):
|
| 542 |
+
"""Pedido de um modelo de fora do catálogo: vai igual para o Ollama da pessoa, e a resposta volta pedaço por pedaço."""
|
| 543 |
+
pedido = urllib.request.Request(OLLAMA + self.path, json.dumps(corpo).encode(), {"Content-Type": "application/json"}, method=self.command)
|
| 544 |
+
try:
|
| 545 |
+
r = urllib.request.urlopen(pedido, timeout=900)
|
| 546 |
+
except urllib.error.HTTPError as e:
|
| 547 |
+
r = e
|
| 548 |
+
except OSError:
|
| 549 |
+
return self._json({"error": f"model '{corpo.get('model') or corpo.get('name')}' not found"}, 404)
|
| 550 |
+
with r:
|
| 551 |
+
self.send_response(r.status)
|
| 552 |
+
self.send_header("Content-Type", r.headers.get("Content-Type") or "application/json")
|
| 553 |
+
self.send_header("Transfer-Encoding", "chunked")
|
| 554 |
+
self.end_headers()
|
| 555 |
+
for bloco in iter(lambda: r.read1(1 << 16), b""):
|
| 556 |
+
self.wfile.write(f"{len(bloco):x}\r\n".encode() + bloco + b"\r\n")
|
| 557 |
+
self.wfile.flush()
|
| 558 |
+
self._fim_linhas()
|
| 559 |
+
|
| 560 |
+
def do_DELETE(self):
|
| 561 |
+
corpo = self._corpo()
|
| 562 |
+
nome = _nome(corpo.get("model") or corpo.get("name"))
|
| 563 |
+
if nome not in CATALOGO:
|
| 564 |
+
return self._repassar(corpo)
|
| 565 |
+
motor.descarregar(nome)
|
| 566 |
+
ficha = CATALOGO[nome]
|
| 567 |
+
for arquivo in [ficha["gguf"]] + ([ficha["mmproj"]] if ficha.get("mmproj") else []):
|
| 568 |
+
(pasta_modelos() / arquivo[0]).unlink(missing_ok=True)
|
| 569 |
+
self._json({})
|
| 570 |
+
|
| 571 |
+
def _corpo(self):
|
| 572 |
+
tamanho = int(self.headers.get("Content-Length") or 0)
|
| 573 |
+
try:
|
| 574 |
+
return json.loads(self.rfile.read(tamanho) or b"{}")
|
| 575 |
+
except ValueError:
|
| 576 |
+
return {}
|
| 577 |
+
|
| 578 |
+
def do_POST(self):
|
| 579 |
+
motor.ultimo_pedido = time.time()
|
| 580 |
+
corpo = self._corpo()
|
| 581 |
+
nome = _nome(corpo.get("model") or corpo.get("name"))
|
| 582 |
+
if nome not in CATALOGO or (self.path != "/api/pull" and not instalado(nome) and self.path != "/api/delete"):
|
| 583 |
+
return self._repassar(corpo)
|
| 584 |
+
if self.path == "/api/show":
|
| 585 |
+
if nome not in CATALOGO or not instalado(nome):
|
| 586 |
+
return self._json({"error": f"model '{nome}' not found"}, 404)
|
| 587 |
+
ficha = _ficha_tags(nome)
|
| 588 |
+
return self._json({"capabilities": ficha["capabilities"], "details": ficha["details"], "parameters": CATALOGO[nome]["parameters"],
|
| 589 |
+
# o hash do arquivo instalado: o aviso de modelo novo (tars_gerenciar_modelos.desatualizados) compara com o ollama.com
|
| 590 |
+
"modelfile": f"# {CATALOGO[nome]['gguf'][0]} sha256:{sha_do_arquivo(pasta_modelos() / CATALOGO[nome]['gguf'][0])}", "template": "", "model_info": {"general.architecture": CATALOGO[nome]["family"]}})
|
| 591 |
+
if self.path == "/api/pull":
|
| 592 |
+
return self._puxar(nome, corpo.get("stream", True))
|
| 593 |
+
if self.path == "/api/delete":
|
| 594 |
+
return self.do_DELETE()
|
| 595 |
+
if self.path == "/api/generate":
|
| 596 |
+
return self._gerar(nome, corpo)
|
| 597 |
+
if self.path == "/api/chat":
|
| 598 |
+
return self._conversar(nome, corpo)
|
| 599 |
+
self._json({"error": "not found"}, 404)
|
| 600 |
+
|
| 601 |
+
def _gerar(self, nome, corpo):
|
| 602 |
+
ka = _keep_alive(corpo.get("keep_alive"))
|
| 603 |
+
if nome not in CATALOGO or not instalado(nome):
|
| 604 |
+
return self._json({"error": f"model '{nome}' not found"}, 404)
|
| 605 |
+
if not corpo.get("prompt"):
|
| 606 |
+
if ka == 0:
|
| 607 |
+
motor.descarregar(nome)
|
| 608 |
+
return self._json({"model": nome, "response": "", "done": True, "done_reason": "unload"})
|
| 609 |
+
try:
|
| 610 |
+
motor.instancia(nome).keep_alive = ka # carregar na memória e deixar quente
|
| 611 |
+
except (RuntimeError, KeyError) as e:
|
| 612 |
+
return self._json({"error": str(e)}, 500)
|
| 613 |
+
return self._json({"model": nome, "response": "", "done": True, "done_reason": "load"})
|
| 614 |
+
chat = {"messages": [{"role": "user", "content": corpo["prompt"], **({"images": corpo["images"]} if corpo.get("images") else {})}],
|
| 615 |
+
"stream": False, "options": corpo.get("options"), "think": corpo.get("think", False), "keep_alive": corpo.get("keep_alive")}
|
| 616 |
+
if corpo.get("system"):
|
| 617 |
+
chat["messages"].insert(0, {"role": "system", "content": corpo["system"]})
|
| 618 |
+
resposta = self._responder(nome, chat)
|
| 619 |
+
if "error" in resposta:
|
| 620 |
+
return self._json(resposta, 500)
|
| 621 |
+
return self._json({"model": nome, "response": resposta["message"]["content"], "done": True, "done_reason": resposta["done_reason"]})
|
| 622 |
+
|
| 623 |
+
def _responder(self, nome, corpo):
|
| 624 |
+
"""Pedido sem streaming: o último pedaço do /api/chat."""
|
| 625 |
+
inicio = time.time()
|
| 626 |
+
try:
|
| 627 |
+
inst = motor.instancia(nome, _tem_imagem(corpo))
|
| 628 |
+
inst.keep_alive = _keep_alive(corpo.get("keep_alive"))
|
| 629 |
+
pedido = dict(para_openai(corpo, nome), stream=False)
|
| 630 |
+
with _pedir(f"http://127.0.0.1:{inst.porta}/v1/chat/completions", pedido) as r:
|
| 631 |
+
dados = json.loads(r.read())
|
| 632 |
+
inst.ultimo = time.time()
|
| 633 |
+
except urllib.error.HTTPError as e:
|
| 634 |
+
return {"error": e.read().decode("utf-8", "replace")[:300]}
|
| 635 |
+
except (OSError, RuntimeError, KeyError, ValueError) as e:
|
| 636 |
+
return {"error": str(e)}
|
| 637 |
+
escolha = dados["choices"][0]
|
| 638 |
+
m = escolha["message"]
|
| 639 |
+
mensagem = {"role": "assistant", "content": m.get("content") or ""}
|
| 640 |
+
if m.get("reasoning_content"):
|
| 641 |
+
mensagem["thinking"] = m["reasoning_content"]
|
| 642 |
+
if m.get("tool_calls"):
|
| 643 |
+
mensagem["tool_calls"] = _chamadas_ollama(m["tool_calls"])
|
| 644 |
+
logprobs = [{"token": x.get("token"), "logprob": x.get("logprob")} for x in (escolha.get("logprobs") or {}).get("content") or []]
|
| 645 |
+
return _final(nome, inicio, mensagem, escolha.get("finish_reason"), logprobs or None)
|
| 646 |
+
|
| 647 |
+
def _conversar(self, nome, corpo):
|
| 648 |
+
if nome not in CATALOGO or not instalado(nome):
|
| 649 |
+
return self._json({"error": f"model '{nome}' not found"}, 404)
|
| 650 |
+
if not corpo.get("stream", True):
|
| 651 |
+
resposta = self._responder(nome, corpo)
|
| 652 |
+
return self._json(resposta, 500 if "error" in resposta else 200)
|
| 653 |
+
inicio = time.time()
|
| 654 |
+
try:
|
| 655 |
+
inst = motor.instancia(nome, _tem_imagem(corpo))
|
| 656 |
+
inst.keep_alive = _keep_alive(corpo.get("keep_alive"))
|
| 657 |
+
r = _pedir(f"http://127.0.0.1:{inst.porta}/v1/chat/completions", dict(para_openai(corpo, nome), stream=True))
|
| 658 |
+
except urllib.error.HTTPError as e:
|
| 659 |
+
return self._json({"error": e.read().decode("utf-8", "replace")[:300]}, 500)
|
| 660 |
+
except (OSError, RuntimeError, KeyError) as e:
|
| 661 |
+
return self._json({"error": str(e)}, 500)
|
| 662 |
+
self._linhas()
|
| 663 |
+
chamadas, motivo, logprobs = {}, "stop", []
|
| 664 |
+
with r:
|
| 665 |
+
for bruta in r:
|
| 666 |
+
linha = bruta.decode("utf-8", "replace").strip()
|
| 667 |
+
if not linha.startswith("data:"):
|
| 668 |
+
continue
|
| 669 |
+
dado = linha[5:].strip()
|
| 670 |
+
if dado == "[DONE]":
|
| 671 |
+
break
|
| 672 |
+
try:
|
| 673 |
+
pedaco = json.loads(dado)
|
| 674 |
+
except ValueError:
|
| 675 |
+
continue
|
| 676 |
+
for escolha in pedaco.get("choices") or []:
|
| 677 |
+
delta = escolha.get("delta") or {}
|
| 678 |
+
motivo = escolha.get("finish_reason") or motivo
|
| 679 |
+
for c in delta.get("tool_calls") or []: # as chamadas chegam em pedaços: junta pelo índice
|
| 680 |
+
atual = chamadas.setdefault(c.get("index", 0), {"function": {"name": "", "arguments": ""}})
|
| 681 |
+
f = c.get("function") or {}
|
| 682 |
+
atual["function"]["name"] += f.get("name") or ""
|
| 683 |
+
atual["function"]["arguments"] += f.get("arguments") or ""
|
| 684 |
+
logprobs += [{"token": x.get("token"), "logprob": x.get("logprob")} for x in (escolha.get("logprobs") or {}).get("content") or []]
|
| 685 |
+
texto = delta.get("content") or ""
|
| 686 |
+
pensado = delta.get("reasoning_content") or ""
|
| 687 |
+
if texto or pensado:
|
| 688 |
+
mensagem = {"role": "assistant", "content": texto}
|
| 689 |
+
if pensado:
|
| 690 |
+
mensagem["thinking"] = pensado
|
| 691 |
+
self._linha({"model": nome, "message": mensagem, "done": False})
|
| 692 |
+
inst.ultimo = time.time()
|
| 693 |
+
final = {"role": "assistant", "content": ""}
|
| 694 |
+
if chamadas:
|
| 695 |
+
final["tool_calls"] = _chamadas_ollama([chamadas[i] for i in sorted(chamadas)])
|
| 696 |
+
self._linha(_final(nome, inicio, final, motivo, logprobs or None))
|
| 697 |
+
self._fim_linhas()
|
| 698 |
+
|
| 699 |
+
def _puxar(self, nome, stream):
|
| 700 |
+
if nome not in CATALOGO:
|
| 701 |
+
return self._json({"error": f"pull model manifest: file does not exist ({nome})"}, 404)
|
| 702 |
+
ficha = CATALOGO[nome]
|
| 703 |
+
arquivos = [ficha["gguf"]] + ([ficha["mmproj"]] if ficha.get("mmproj") else [])
|
| 704 |
+
if stream:
|
| 705 |
+
self._linhas()
|
| 706 |
+
avisar = (lambda d: self._linha(d)) if stream else (lambda d: None)
|
| 707 |
+
try:
|
| 708 |
+
avisar({"status": "pulling manifest"})
|
| 709 |
+
if binario() is None:
|
| 710 |
+
avisar({"status": "downloading llama.cpp"})
|
| 711 |
+
instalar_binario(lambda f, t: None)
|
| 712 |
+
for arquivo, sha, tamanho in arquivos:
|
| 713 |
+
destino = pasta_modelos() / arquivo
|
| 714 |
+
if _atual(destino, sha):
|
| 715 |
+
avisar({"status": f"pulling {sha[:12]}", "digest": f"sha256:{sha}", "total": tamanho, "completed": tamanho})
|
| 716 |
+
continue
|
| 717 |
+
ultimo = [0.0]
|
| 718 |
+
|
| 719 |
+
def progresso(feito, total, sha=sha, tamanho=tamanho):
|
| 720 |
+
if time.time() - ultimo[0] > 0.5 or feito >= (total or tamanho):
|
| 721 |
+
ultimo[0] = time.time()
|
| 722 |
+
avisar({"status": f"pulling {sha[:12]}", "digest": f"sha256:{sha}", "total": total or tamanho, "completed": feito})
|
| 723 |
+
obter_arquivo(ficha["repo"], arquivo, sha, progresso)
|
| 724 |
+
avisar({"status": "verifying sha256 digest"})
|
| 725 |
+
avisar({"status": "success"})
|
| 726 |
+
except (OSError, ValueError) as e:
|
| 727 |
+
if stream:
|
| 728 |
+
avisar({"error": str(e)})
|
| 729 |
+
else:
|
| 730 |
+
return self._json({"error": str(e)}, 500)
|
| 731 |
+
if stream:
|
| 732 |
+
self._fim_linhas()
|
| 733 |
+
else:
|
| 734 |
+
self._json({"status": "success"})
|
| 735 |
+
|
| 736 |
+
class Servidor(ThreadingHTTPServer):
|
| 737 |
+
def server_bind(self): # o original chama socket.getfqdn(), que no macOS travou mais de 20 s (CI de 07/11)
|
| 738 |
+
import socketserver
|
| 739 |
+
socketserver.TCPServer.server_bind(self)
|
| 740 |
+
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
| 741 |
+
|
| 742 |
+
servidor = Servidor(("127.0.0.1", PORTA), Pedido)
|
| 743 |
+
servidor.daemon_threads = True
|
| 744 |
+
print(f"motor escutando em {URL}", file=sys.stderr, flush=True)
|
| 745 |
+
|
| 746 |
+
def vigiar():
|
| 747 |
+
while True:
|
| 748 |
+
time.sleep(15)
|
| 749 |
+
motor.faxina()
|
| 750 |
+
threading.Thread(target=vigiar, daemon=True).start()
|
| 751 |
+
import signal
|
| 752 |
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) # sem isso o finally não roda e os llama-server ficam órfãos (visto em 13/11)
|
| 753 |
+
try:
|
| 754 |
+
servidor.serve_forever()
|
| 755 |
+
finally:
|
| 756 |
+
motor.parar_tudo()
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
# ---------------------------------------------------------------- o lado do app
|
| 760 |
+
|
| 761 |
+
def respondendo(tempo=1.0):
|
| 762 |
+
try:
|
| 763 |
+
with urllib.request.urlopen(f"{URL}/api/version", timeout=tempo) as r:
|
| 764 |
+
return r.status == 200
|
| 765 |
+
except (OSError, ValueError):
|
| 766 |
+
return False
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
def garantir(espera=15):
|
| 770 |
+
"""Deixa o motor de pé (sobe em segundo plano se não estiver) e devolve o endereço, ou None se não deu."""
|
| 771 |
+
if respondendo():
|
| 772 |
+
return URL
|
| 773 |
+
if binario() is None: # sem modelo também sobe: o primeiro uso baixa os modelos pelo /api/pull dele
|
| 774 |
+
return None
|
| 775 |
+
extra = {"creationflags": 0x08000000 | 0x00000200} if plataforma.sistema() == "windows" else {"start_new_session": True}
|
| 776 |
+
pasta().mkdir(parents=True, exist_ok=True)
|
| 777 |
+
with open(pasta() / "motor.log", "ab") as registro:
|
| 778 |
+
subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--servir"], stdin=subprocess.DEVNULL, stdout=registro, stderr=registro,
|
| 779 |
+
cwd=str(Path(__file__).resolve().parent), **extra)
|
| 780 |
+
limite = time.time() + espera
|
| 781 |
+
while time.time() < limite:
|
| 782 |
+
if respondendo(0.5):
|
| 783 |
+
return URL
|
| 784 |
+
time.sleep(0.2)
|
| 785 |
+
return None
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
if __name__ == "__main__":
|
| 789 |
+
if "--servir" in sys.argv:
|
| 790 |
+
if os.environ.get("TARS_MOTOR_DEPURAR"): # o CI do macOS: se travar, a pilha vai para o motor.log
|
| 791 |
+
import faulthandler
|
| 792 |
+
faulthandler.dump_traceback_later(20, exit=False)
|
| 793 |
+
servir()
|
| 794 |
+
elif "--instalar" in sys.argv: # baixa o llama-server e os modelos pedidos (ou o 2B e o Lite)
|
| 795 |
+
nomes = [a for a in sys.argv[2:] if not a.startswith("-")] or ["ProjectOpenTARS/Gargantua-2.5:lite", "ProjectOpenTARS/Gargantua-2.5:latest"]
|
| 796 |
+
preparar(nomes)
|
| 797 |
+
print("pronto:", instalados())
|