Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/run_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Bound collection, execution and Qt shutdown; retain output on CI failures."""
import pathlib
import subprocess
import sys


groups = {
"backend": ["--ignore=tests/test_avatar_tray.py", "--ignore=tests/test_desktop_speaker_control.py"],
"avatar": ["tests/test_avatar_tray.py"],
"speaker": ["tests/test_desktop_speaker_control.py"],
}
failed = False
for name, selection in groups.items():
log = pathlib.Path(f"test-{name}.log")
print(f"Starting {name}", flush=True)
bootstrap = (
"import faulthandler,pytest; "
"faulthandler.dump_traceback_later(60); "
"raise SystemExit(pytest.main())"
)
command = [sys.executable, "-u", "-c", bootstrap, "-vv",
"--timeout=45", "--timeout-method=thread",
f"--junitxml=test-{name}.xml", *selection]
try:
with log.open("w", encoding="utf-8") as output:
result = subprocess.run(command, stdout=output, stderr=subprocess.STDOUT,
timeout=120, check=False)
failed |= result.returncode != 0
except subprocess.TimeoutExpired:
failed = True
print(f"{name}: exceeded 120 seconds", flush=True)
finally:
print(log.read_text(encoding="utf-8", errors="replace")[-16000:], flush=True)
raise SystemExit(1 if failed else 0)
20 changes: 18 additions & 2 deletions .github/workflows/cross-platform-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ jobs:
- windows-latest

runs-on: ${{ matrix.os }}
timeout-minutes: 15
env:
QT_QPA_PLATFORM: offscreen
PYTHONUTF8: "1"
PYTHONIOENCODING: utf-8

steps:
- uses: actions/checkout@v4
Expand All @@ -34,7 +39,7 @@ jobs:
cache-dependency-path: components/TrinityCanvas/package-lock.json

- name: Install test tools
run: python -m pip install pytest requests numpy
run: python -m pip install pytest pytest-timeout requests numpy PySide6

- name: Compile Python sources
run: >-
Expand All @@ -43,7 +48,18 @@ jobs:
trinity_console.py trinity_chat.py trinity_cli.py

- name: Run unit tests
run: python -m pytest
timeout-minutes: 7
run: python -u .github/run_tests.py

- name: Preserve test diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}
path: |
test-*.xml
test-*.log
if-no-files-found: warn

- name: Build and smoke-test Trinity Canvas
working-directory: components/TrinityCanvas
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
![Trinity Assistant Banner](assets/banner.png)
> [!NOTE]
> **Aktuelle Highlights:**
> - **v0.17.11:** Aktuelle iPad-Folien als Bild und Text im Chat-/Sprachkontext, aussagekräftige Modellfehler und kompakte MiniTrinity mit Audio-Farbstatus. Bestehende Eve-/STT-Verbindungen bleiben unverändert. Für BIZ-Updates: [sichere Windows-Übergabe](docs/WINDOWS_UPDATE_v0.17.11.md).
> - **v0.17.10:** Die G2-Spracherkennung reagiert schneller und robuster: Digitale Stille, unsichere No-Speech-Segmente und bekannte Whisper-Untertitelartefakte wie `Copyright WDR`, `Amara.org` oder erfundene Schnellsession-Domains werden verworfen. Der bisher zu starke Fachwort-Bias ist auf das eigentliche Wakeword `Trinity` reduziert.
> - **v0.17.9:** Eine Trinity-Instanz in einer Windows-VM kann Eve-STT und Eve-TTS jetzt von einem privaten Ubuntu-NVIDIA-Host beziehen. Windows bleibt Control Plane mit Sessions, Memory, Agenten und UI; Ubuntu uebernimmt nur GPU-Inferenz. Dafuer gibt es die Profile `eve-windows-remote` und `eve-linux-gpu-server`, getrennte Tokens, Diagnosepruefungen und Installationsskripte.
> - **v0.17.8:** ClassicUI, iPhone und iPad zeigen nur noch einen zentralen Lautsprecherknopf. Aktiviert ein Client seine Ausgabe, wird er Bridge-weit zum einzigen Sprecher und alle anderen verbundenen Clients zeigen den durchgestrichenen Lautsprecher. Ein erneuter Klick auf dem aktiven Geraet schaltet Trinity ueberall stumm; auch Eves direkter Desktop- und Companion-Audiopfad folgt dieser Auswahl.
> - Die vollstaendige Historie steht in **[RELEASES.md](RELEASES.md)** und in den detaillierten **[Release Notes](docs/release_notes/)**.

> [!IMPORTANT]
Expand Down
2 changes: 2 additions & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Einzelnotizen liegen unter [docs/release_notes](docs/release_notes/).

## Aktuelle Highlights

- **v0.17.11:** Foliensehen: authentifizierte Übergabe der aktuellen Companion-Folie an Chat und Sprache, begrenzt auf Profil/Session und 90 Sekunden ohne Erneuerung. MiniTrinity mit Orange/Weiß-Audiostatus, optionalem Punkt und Desktop-Sprechstellenmenü. Keine Änderungen an Eve-/STT-Engine, Modellverbindungen, Installer oder Konfigurationsdefaults. Siehe [Release-Details](docs/release_notes/v0.17.11.md) und [Windows-Update](docs/WINDOWS_UPDATE_v0.17.11.md).

- **v0.17.10:** Die G2-Audiobridge nutzt einen neutralen deutschen Erkennungskontext mit `Trinity` als einzigem Hotword. Digitale Stille, unsichere No-Speech-Segmente und typische Whisper-Untertitelhalluzinationen wie `Copyright WDR`, `Amara.org` oder erfundene Schnellsession-Domains werden vor dem Routing verworfen. Kleinere Beam-Suchen senken die G2-Latenz, ohne Desktop-, Companion- oder Eve-Sprachpfade zu veraendern.
- **v0.17.9:** Windows kann als Trinity-Control-Plane in einer VM laufen, waehrend ein privater Ubuntu-Host mit NVIDIA-GPU Parakeet-STT, Qwen3-TTS/Eve und das konfigurierte OpenAI-kompatible LLM bereitstellt. Die neuen Profile `eve-windows-remote` und `eve-linux-gpu-server`, getrennte Voice-/Core-Tokens, Remote-Diagnosen und Installationsskripte vermeiden unnoetiges GPU-Passthrough. Native Mac- und Windows-Eve-Profile sowie Legacy STT/TTS bleiben unveraendert waehlbar.
- **v0.17.8:** ClassicUI, iPhone und iPad verwenden einen einzigen Bridge-weiten Lautsprecherknopf. Das ausgewaehlte Geraet ist exklusiv; alle anderen Clients werden samt direkter Eve-Wiedergabe sofort stumm und zeigen den durchgestrichenen Lautsprecher. Ein zweiter Klick auf dem aktiven Geraet setzt die Ausgabe explizit auf `Stumm`, statt sie unbemerkt an einen anderen Client weiterzureichen.
Expand Down
167 changes: 167 additions & 0 deletions core/avatar_tray.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Small, gently blinking Trinity eyes for the desktop menu bar."""

import random
from pathlib import Path

from PySide6.QtCore import QObject, QRectF, QSettings, Qt, QTimer
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
from desktop_audio_activity import desktop_audio_active


def eyes_icon(state="idle", closed=False, audio_active=False, light=True,
audio_color=True, show_dot=False):
# Use a taller face aspect ratio: macOS fits the entire icon into its status
# item, so a wide, shallow canvas makes the face look unexpectedly tiny.
# Render at 2x for Retina; reserve space outside the visor for audio activity.
pixmap = QPixmap(120 if show_dot else 96, 76)
pixmap.setDevicePixelRatio(2)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(Qt.NoPen)
face_color = ("#ff9f28" if audio_active else "#e8eeee") if audio_color else (
"#e8eeee" if light else "#0a0a0a")
painter.setBrush(QColor(face_color))
painter.drawRoundedRect(QRectF(1, 1, 45, 36), 15, 15)
height = 2 if closed else (9 if state == "listening" else 6)
painter.setBrush(QColor("#23b477"))
for x in (10, 28):
painter.drawRoundedRect(QRectF(x, 19 - height / 2, 9, height), 2.25, 2.25)
if audio_active and show_dot:
painter.setBrush(QColor("#f0a044"))
painter.drawEllipse(QRectF(51, 15, 8, 8))
painter.end()
return QIcon(pixmap)


class AvatarTray(QObject):
def __init__(self, window, speaker_control, settings=None, home=None):
super().__init__(window)
self.window = window
self.settings = settings if settings is not None else QSettings("Trinity", "DesktopAvatar")
self.compact = False
self.state = "idle"
self.notification = False
self.closed = False
self.home = Path(home) if home is not None else Path(__file__).resolve().parents[1]
self.audio_active = desktop_audio_active(self.home)
self.light = self.settings.value("lightAppearance", True, type=bool)
self.audio_color = self.settings.value("audioColor", True, type=bool)
self.show_dot = self.settings.value("showAudioDot", False, type=bool)
self.icon = QSystemTrayIcon(eyes_icon(audio_active=self.audio_active, light=self.light), self)
self.icon.setToolTip("Trinity")
self.menu = QMenu(window)
self.menu.addAction("Avatar anzeigen", self.restore)
self.menu.addAction("Chat öffnen", window.open_chat)
self.result_action = self.menu.addAction("Ergebnis / Hinweis anzeigen", window.show_latest_content)
self.light_action = self.menu.addAction("Weiße MiniTrinity")
self.light_action.setCheckable(True)
self.light_action.setChecked(self.light)
self.light_action.toggled.connect(self.set_light)
self.audio_color_action = self.menu.addAction("Audio durch Farbe anzeigen (Orange/Weiß)")
self.audio_color_action.setCheckable(True)
self.audio_color_action.setChecked(self.audio_color)
self.audio_color_action.toggled.connect(self.set_audio_color)
self.dot_action = self.menu.addAction("Audiopunkt anzeigen")
self.dot_action.setCheckable(True)
self.dot_action.setChecked(self.show_dot)
self.dot_action.toggled.connect(self.set_show_dot)
self.light_action.setEnabled(not self.audio_color)
self.menu.addSeparator()
self.menu.addAction("Hier auf diesem Computer antworten", speaker_control.claim)
self.menu.addAction("Sprachausgabe stumm", speaker_control.mute)
self.menu.addSeparator()
self.menu.addAction("Einstellungen …", window.open_settings)
self.menu.addAction("Trinity beenden", QApplication.instance().quit)
self.icon.setContextMenu(self.menu)
self.blink_timer = QTimer(self)
self.blink_timer.setSingleShot(True)
self.blink_timer.timeout.connect(self._blink)
self.open_timer = QTimer(self)
self.open_timer.setSingleShot(True)
self.open_timer.setInterval(140)
self.open_timer.timeout.connect(self._open_eyes)
self.audio_timer = QTimer(self)
self.audio_timer.setInterval(500)
self.audio_timer.timeout.connect(self.refresh_audio_activity)
self.audio_timer.start()
self._redraw()

def apply_startup_mode(self):
if self.settings.value("menuBarOnly", False, type=bool):
if self.minimize():
return
self.window.show()

def minimize(self):
# Never hide the only way back on desktops without a system tray.
if not QSystemTrayIcon.isSystemTrayAvailable():
return False
self.compact = True
self.icon.show()
self.window.hide()
self.window.chat_window.hide()
self.window.content_window.hide()
self.settings.setValue("menuBarOnly", True)
self.blink_timer.start(random.randint(5000, 9000))
return True

def restore(self):
self.compact = False
self.blink_timer.stop()
self.open_timer.stop()
self.closed = False
self.icon.hide()
self.settings.setValue("menuBarOnly", False)
self.window.show()
self.window.raise_()

def set_state(self, state):
self.state = state
self._redraw()

def set_notification(self, enabled):
self.notification = enabled
self.result_action.setText("Ergebnis / Hinweis anzeigen" + (" · Neu" if enabled else ""))

def set_light(self, enabled):
self.light = enabled
self.settings.setValue("lightAppearance", enabled)
self._redraw()

def refresh_audio_activity(self):
active = desktop_audio_active(self.home)
if active != self.audio_active:
self.audio_active = active
self._redraw()

def set_audio_color(self, enabled):
self.audio_color = enabled
self.settings.setValue("audioColor", enabled)
self.light_action.setEnabled(not enabled)
self._redraw()

def set_show_dot(self, enabled):
self.show_dot = enabled
self.settings.setValue("showAudioDot", enabled)
self._redraw()

def _redraw(self):
self.icon.setIcon(eyes_icon(self.state, self.closed, self.audio_active, self.light,
self.audio_color, self.show_dot))
self.icon.setToolTip("Trinity · Mikrofon oder Sprachausgabe aktiv" if self.audio_active
else "Trinity · Mikrofon und Sprachausgabe inaktiv")

def _blink(self):
if not self.compact:
return
self.closed = True
self._redraw()
self.open_timer.start()

def _open_eyes(self):
self.closed = False
self._redraw()
if self.compact:
self.blink_timer.start(random.randint(5000, 9000))
16 changes: 13 additions & 3 deletions core/brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from memory_store import MemoryStore
from skill_registry import SkillRegistry
from task_orchestrator import TaskOrchestrator
from lecture_context import add_current_slide


class TrinityBrain:
Expand Down Expand Up @@ -435,6 +436,9 @@ def ask(
soul_prompt = self.get_soul()
user_prompt = self.get_user()
attachment_content = prepare_attachment_content(user_query, attachments or [])
slide_image = False
if getattr(self, "config_path", None):
slide_image = add_current_slide(attachment_content, os.path.dirname(os.path.dirname(self.config_path)))
primary_image_path = attachment_content["primary_image_path"]
if primary_image_path:
self.last_media_path = primary_image_path
Expand Down Expand Up @@ -535,7 +539,7 @@ def ask(
"attachments": attachments or [],
"task_decision": task_decision,
}
if primary_image_path and not self._skill_allowed_for_image_upload(skill, router_text):
if (primary_image_path or slide_image) and not self._skill_allowed_for_image_upload(skill, router_text):
print(
f"🖼️ Überspringe {getattr(skill, '__name__', 'Skill')} "
"für normale Bildanalyse."
Expand Down Expand Up @@ -577,6 +581,7 @@ def ask(
f"{soul_prompt}\n\n"
f"--- INFORMATIONEN ZUM NUTZER UND ZIELPUBLIKUM ---\n"
f"{user_prompt}\n\n"
f"--- TATSÄCHLICHER FOLIENZUGRIFF ---\n{attachment_content.get('lecture_status', 'Kein automatisch übertragener Folienkontext.')}\n\n"
f"{search_context}"
f"{memory_context}\n\n"
f"--- AKTUELLES VORLESUNGS-TRANSKRIPT ---\n"
Expand Down Expand Up @@ -611,12 +616,13 @@ def ask(
json=data,
timeout=getattr(self, "request_timeout_seconds", 90),
)
if response.status_code >= 400 and primary_image_path:
if response.status_code >= 400 and (primary_image_path or slide_image):
print(
"⚠️ Das aktive Modell hat die Bildeingabe abgelehnt. "
"Wiederhole die Anfrage mit Dateikontext ohne Bilddaten."
)
data["messages"][-1]["content"] = attachment_content["fallback_text"]
data["messages"][0]["content"] += "\nACHTUNG: Der Bildrequest wurde abgelehnt. In dieser Wiederholung ist KEIN Bild verfügbar. Nutze nur Text und benenne diese Einschränkung."
response = requests.post(
self.url,
headers=headers,
Expand Down Expand Up @@ -662,7 +668,11 @@ def ask(
str(e),
succeeded=False,
)
return "Entschuldigung, ich habe gerade den Faden verloren. Bitte wiederhole das.", False
if isinstance(e, requests.exceptions.Timeout):
return "Das eingestellte Modell hat nicht rechtzeitig geantwortet. Ich konnte die Anfrage nicht auswerten.", False
if isinstance(e, requests.exceptions.ConnectionError):
return "Ich erreiche das eingestellte Modell gerade nicht. Bitte prüfe die Modellverbindung.", False
return "Die Modellanfrage ist fehlgeschlagen. Ich konnte den Inhalt nicht zuverlässig auswerten.", False

if __name__ == "__main__":
# Kalttest-Skript
Expand Down
Loading
Loading