diff --git a/core/settings_ui.py b/core/settings_ui.py index cbd10e8..08dacc5 100644 --- a/core/settings_ui.py +++ b/core/settings_ui.py @@ -2656,6 +2656,12 @@ def _test_comfyui_connection(self): "Ubuntu führt Parakeet-STT und Qwen3-TTS auf CUDA aus. Der erkannte Text wird " "an den geschützten Trinity-Core-Endpunkt der Windows-VM weitergereicht.", ), + ( + "Linux als vollständiger Trinity-Server", + "trinity-linux-server", + "Linux führt STT, Trinity-Core mit Memory und Agenten sowie TTS selbst aus. " + "Desktop und Companion verbinden sich als Clients über das private Netzwerk.", + ), ( "Diagnose: Ornith direkt, ohne Trinity", "eve-direct-ornith", @@ -2682,7 +2688,7 @@ def _load_voice_profile_form(self, _selection=None): "Benutzerdefiniertes Eve-Profil.", ) self.voice_profile_description.setText(description) - realtime = profile_name in {"eve-mac-server", "eve-windows-server", "eve-linux-gpu-server"} + realtime = profile_name in {"eve-mac-server", "eve-windows-server", "eve-linux-gpu-server", "trinity-linux-server"} remote_client = profile_name == "eve-windows-remote" remote_server = profile_name == "eve-linux-gpu-server" for field in ( diff --git a/core/voice/config.py b/core/voice/config.py index 7214619..4c0cbdb 100644 --- a/core/voice/config.py +++ b/core/voice/config.py @@ -85,6 +85,20 @@ "tts_model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", "tts_backend": "torch", }, + "trinity-linux-server": { + "mode": "realtime", + "device": "cuda", + "runtime_role": "server", + "conversation_backend": "trinity", + "bind_host": "0.0.0.0", + "public_port": 8766, + "internal_port": 18766, + "local_audio": False, + "num_pipelines": 2, + "stt_model": "nvidia/parakeet-tdt-0.6b-v3", + "tts_model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", + "tts_backend": "torch", + }, "eve-windows-remote": { "mode": "realtime", "device": "cpu", diff --git a/docs/LINUX_UNIFIED_SERVER.md b/docs/LINUX_UNIFIED_SERVER.md new file mode 100644 index 0000000..02b8d82 --- /dev/null +++ b/docs/LINUX_UNIFIED_SERVER.md @@ -0,0 +1,50 @@ +# Trinity ohne Windows-VM: kontrollierter Linux-Server + +Trinity kann den textuellen Kern, Memory, Bridge und die CUDA-Sprachpipeline +auf demselben Linux-Host betreiben. Das Profil `trinity-linux-server` verbindet +Parakeet-STT und Qwen3-TTS mit dem **lokalen** Trinity-Kern. Das bisherige +`eve-linux-gpu-server` bleibt unverändert und leitet weiterhin an einen +externen Kern weiter. Standalone-Profile auf Mac und Windows bleiben erhalten. + +## Vor dem Umschalten + +1. Die laufende Linux-Installation, lokale Änderungen, Dienste und Ports + inventarisieren. Nie über eine aktive Installation hinweg blind pullen. +2. Betriebsdaten, Konfiguration, Soul/User, RAG, Memory und Schlüssel getrennt + vom Git-Checkout sichern. Keine Secrets in Git oder Service-Logs schreiben. +3. Linux-Trinity in einem separaten Checkout mit eigener Runtime und freien + Testports installieren. Die bestehende Voice-Pipeline und die Windows-VM + bleiben dabei aktiv. +4. Im privaten `core/config.json` LLM-Endpunkt, Persona, Memory und benötigte + Agenten gezielt konfigurieren. Die Windows-Konfiguration nicht vollständig + kopieren: Windows-Pfade und gerätespezifische Integrationen sind unbrauchbar. +5. Bridge und Voice nur an Tailnet/private Interfaces binden und mit eigenen + Zugangstokens schützen. Für iPhone/iPad müssen Bridge- und Voice-Adresse + auf denselben Linux-Host zeigen. Niemals öffentliche Ports öffnen. + +## Start als ein betreuter Prozess + +Nach erfolgreichem Test und mit freien Ports: + +```text +python trinity_cli.py server --host 100.70.50.6 --port 8765 \ + --voice-profile trinity-linux-server +``` + +Der Server betreut Trinity-Kern, Bridge und Sprachpipeline. Ohne +`--voice-profile` bleibt das bisherige server-only-Verhalten unverändert. +Der Sprachpfad ist erst nach einem echten STT→Memory/LLM→TTS-Test freigegeben; +ein HTTP-Healthcheck allein genügt nicht. + +## Noch nicht Teil dieses Schritts + +- Der Desktop-Umschalter zu einem reinen Remote-Client ist noch zu bauen; + die bisherige Desktop-Checkbox allein routet noch nicht alle Funktionen. +- Bildschirminhalt, Folien und Dateien brauchen einen authentifizierten + Upload-/Kontextkanal mit Freigabe am jeweiligen Client. Dateien dürfen nicht + nur als lokaler Pfad an den Linux-Host gesendet werden. +- Der TrinityHUB ist ein eigener Dienst. Aufrufe seiner Werkstätten benötigen + definierte APIs und Berechtigungen; ein eingebetteter Browser ist noch keine + sichere Agentenverbindung. +- Alte Windows-Dienste erst nach Vergleich von Antworten, Memory, Agenten, + Vortrags-Vision, Mobilgeräten und Rückfallweg abschalten. diff --git a/tests/test_cli.py b/tests/test_cli.py index facb17a..22246f7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -334,3 +334,24 @@ def test_server_uses_saved_server_settings(tmp_path, monkeypatch): assert result == 0 assert captured == {"home": home, "host": "0.0.0.0", "port": 8888, "token": "secret"} + + +def test_server_passes_optional_linux_voice_profile(tmp_path, monkeypatch): + import trinity_server + + home = tmp_path + (home / "core").mkdir() + (home / "trinity_launcher.py").touch() + save_config(home / "core" / "config.json", { + "server": {"voice_profile": "trinity-linux-server"} + }) + captured = {} + monkeypatch.setattr(trinity_server, "run_server", lambda home_arg, **kwargs: + captured.update(kwargs) or 0) + + result = trinity_cli.run_server_command( + home, SimpleNamespace(host=None, port=None, token=None, voice_profile=None) + ) + + assert result == 0 + assert captured["voice_profile"] == "trinity-linux-server" diff --git a/tests/voice/test_voice_command.py b/tests/voice/test_voice_command.py index 75bd6ca..6b244ed 100644 --- a/tests/voice/test_voice_command.py +++ b/tests/voice/test_voice_command.py @@ -66,3 +66,14 @@ def test_ubuntu_server_uses_remote_windows_trinity_core(tmp_path): assert command[command.index("--responses_api_base_url") + 1] == "http://100.64.0.20:18767/v1" assert command[command.index("--responses_api_api_key") + 1] == "core-secret" assert command[command.index("--model_name") + 1] == "trinity-core" + + +def test_linux_trinity_server_routes_voice_to_its_local_core(tmp_path): + config = configured_voice(tmp_path, profile="trinity-linux-server") + command = build_speech_to_speech_command(config) + + assert command[command.index("--responses_api_base_url") + 1] == ( + f"http://{config.backend_host}:{config.backend_port}/v1" + ) + assert command[command.index("--device") + 1] == "cuda" + assert "remote_core_base_url" not in command diff --git a/tests/voice/test_voice_config.py b/tests/voice/test_voice_config.py index dd0cd0f..f2a31d1 100644 --- a/tests/voice/test_voice_config.py +++ b/tests/voice/test_voice_config.py @@ -125,6 +125,23 @@ def test_ubuntu_gpu_server_routes_back_to_windows_core(tmp_path): assert config.validate() == [] +def test_linux_trinity_server_uses_local_core_without_windows_vm(tmp_path): + audio = tmp_path / "Trinity.mp3" + audio.write_bytes(b"voice") + config = load_voice_config( + tmp_path, + {"voice": {"engine": "eve", "profile": "trinity-linux-server", + "access_token": "voice-secret", "reference_audio": str(audio), + "backend_token": "local-core-secret"}}, + ) + + assert config.profile.runtime_role == "server" + assert config.profile.conversation_backend == "trinity" + assert config.profile.device == "cuda" + assert config.profile.local_audio is False + assert config.validate() == [] + + def test_windows_remote_profile_needs_no_local_voice_models(tmp_path): config = load_voice_config( tmp_path, diff --git a/trinity_cli.py b/trinity_cli.py index d67cb21..13ab4a2 100644 --- a/trinity_cli.py +++ b/trinity_cli.py @@ -639,9 +639,11 @@ def run_server_command(home, args): port = args.port or server.get("port") or 8765 token = args.token if args.token is not None else server.get("token", "") auth_enabled = bool(getattr(args, "auth", False) or server.get("auth_enabled", False)) + voice_profile = getattr(args, "voice_profile", None) or server.get("voice_profile") + extra = {"voice_profile": voice_profile} if voice_profile else {} if auth_enabled: - return run_server(home, host=host, port=port, token=token, auth_enabled=True) - return run_server(home, host=host, port=port, token=token) + return run_server(home, host=host, port=port, token=token, auth_enabled=True, **extra) + return run_server(home, host=host, port=port, token=token, **extra) def run_client_command(home, args): @@ -1126,6 +1128,8 @@ def build_parser(): server.add_argument("--port", type=int, default=None, help="HTTP-Port") server.add_argument("--token", default=None, help="Bearer-Token für die WebUI") server.add_argument("--auth", action="store_true", help="Passwort-Accounts und getrennte Nutzerbereiche aktivieren") + server.add_argument("--voice-profile", default=None, + help="Sprachpipeline mitstarten, z.B. trinity-linux-server") client = subparsers.add_parser("client", help="Diese Desktop-Installation mit einem Trinity-Server verbinden") client.add_argument("client_action", choices=("login", "status", "logout", "add-user")) client.add_argument("--url", help="URL des Trinity-Servers, z.B. http://100.x.y.z:8765") diff --git a/trinity_server.py b/trinity_server.py index e808f6d..c0a7f43 100644 --- a/trinity_server.py +++ b/trinity_server.py @@ -14,9 +14,15 @@ def _terminate(process): if process and process.poll() is None: process.terminate() + try: + process.wait(timeout=8) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) -def run_server(home, host="127.0.0.1", port=8765, token="", auth_enabled=False): +def run_server(home, host="127.0.0.1", port=8765, token="", auth_enabled=False, + voice_profile=None): home = Path(home).resolve() logs = home / "logs" logs.mkdir(parents=True, exist_ok=True) @@ -31,22 +37,38 @@ def run_server(home, host="127.0.0.1", port=8765, token="", auth_enabled=False): if auth_enabled: bridge_command.append("--auth") bridge = subprocess.Popen(bridge_command, cwd=home, env=env, stdout=bridge_log, stderr=subprocess.STDOUT) + voice_log = None + voice = None + if voice_profile: + voice_log = (logs / "server-voice.log").open("a", encoding="utf-8") + voice = subprocess.Popen( + [sys.executable, "-u", str(home / "trinity_cli.py"), "voice", "serve", "--profile", voice_profile], + cwd=home, env=env, stdout=voice_log, stderr=subprocess.STDOUT, + ) print(f"Trinity Server laeuft auf http://{host}:{port}") + if voice_profile: + print(f"Trinity Voice laeuft mit Profil {voice_profile}; Details: logs/server-voice.log") if auth_enabled: print("WebUI: / | Erster Aufruf: Admin-Account anlegen | getrennte Nutzerbereiche aktiv") else: print("WebUI: / | Logs: logs/server-runtime.log und logs/server-web.log") + processes = [runtime, bridge] + ([voice] if voice is not None else []) + exit_code = 0 try: - while runtime.poll() is None and bridge.poll() is None: + while all(process.poll() is None for process in processes): time.sleep(0.4) + exit_code = next((int(process.returncode or 1) for process in processes + if process.poll() is not None), 1) except KeyboardInterrupt: print("\nTrinity Server wird beendet.") finally: - _terminate(runtime) - _terminate(bridge) + for process in reversed(processes): + _terminate(process) runtime_log.close() bridge_log.close() - return runtime.returncode or bridge.returncode or 0 + if voice_log is not None: + voice_log.close() + return exit_code def main(argv=None): @@ -56,8 +78,11 @@ def main(argv=None): parser.add_argument("--port", type=int, default=8765) parser.add_argument("--token", default=os.environ.get("TRINITY_WEB_TOKEN", "")) parser.add_argument("--auth", action="store_true", help="Passwort-Accounts und getrennte Nutzerbereiche aktivieren") + parser.add_argument("--voice-profile", default=None, + help="Sprachpipeline mitstarten, z.B. trinity-linux-server") args = parser.parse_args(argv) - return run_server(args.home, args.host, args.port, args.token, args.auth) + return run_server(args.home, args.host, args.port, args.token, args.auth, + voice_profile=args.voice_profile) if __name__ == "__main__":