feat: connect desktop sessions and streaming
This commit is contained in:
@@ -111,6 +111,10 @@ Rootserver:
|
|||||||
wird lesbar bereinigt. Strg+C verwirft Teilantworten atomar und kehrt zum Chat zurück.
|
wird lesbar bereinigt. Strg+C verwirft Teilantworten atomar und kehrt zum Chat zurück.
|
||||||
- Desktop-MVP auf `feat/desktop-ui` begonnen: PySide6, gemeinsamer CLI-/GUI-
|
- Desktop-MVP auf `feat/desktop-ui` begonnen: PySide6, gemeinsamer CLI-/GUI-
|
||||||
Runtime-Builder, Qt-freier Controller und ruhiges Drei-Spalten-Grundgerüst vorhanden.
|
Runtime-Builder, Qt-freier Controller und ruhiges Drei-Spalten-Grundgerüst vorhanden.
|
||||||
|
- Sitzungen, Suche, Umbenennen, Markdownanzeige, Qt-Streamingworker, sicherer
|
||||||
|
Abbruch, Modus und bereinigte Statusanzeige sind im Desktopclient verdrahtet.
|
||||||
|
- `ask` unterstützt `Einmal erlauben`, `Lokal beantworten` und `Abbrechen`;
|
||||||
|
`never` erreicht weiterhin keinen Cloudfreigabedialog.
|
||||||
|
|
||||||
## Aktuelle Architektur
|
## Aktuelle Architektur
|
||||||
|
|
||||||
@@ -191,8 +195,8 @@ Letzter bestätigter Projektstand:
|
|||||||
- uv-Lock und `uv sync --dev`: bestanden
|
- uv-Lock und `uv sync --dev`: bestanden
|
||||||
- Python in `.venv`: 3.12.13
|
- Python in `.venv`: 3.12.13
|
||||||
- Ruff in `.venv`: 0.16.0
|
- Ruff in `.venv`: 0.16.0
|
||||||
- Unit-Tests: 94 bestanden; zusätzlich Runtime-Adapter, Qt-freier Controller,
|
- Unit-Tests: 97 bestanden; zusätzlich GUI-Streamingereignisse, atomarer Abbruch
|
||||||
Secretmaskierung sowie neuer und bestehender CLI-Parserpfad abgedeckt
|
und alle drei Ergebnisse des Datenschutzdialogs abgedeckt
|
||||||
- PowerShell-Syntax des Startskripts: erfolgreich geparst
|
- PowerShell-Syntax des Startskripts: erfolgreich geparst
|
||||||
- Ruff Lint: bestanden
|
- Ruff Lint: bestanden
|
||||||
- Ruff Formatprüfung: bestanden
|
- Ruff Formatprüfung: bestanden
|
||||||
@@ -248,8 +252,6 @@ Abnahmestatus:
|
|||||||
- Chatkomfort-Merge nach `main`: `cbba102`
|
- Chatkomfort-Merge nach `main`: `cbba102`
|
||||||
- 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
|
- 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
|
||||||
- Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht
|
- Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht
|
||||||
- konfliktfreier Merge nach `main`: `564a3fd`
|
|
||||||
- 66 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
|
|
||||||
- `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
|
- `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
|
||||||
- CLI-Verdrahtung: `af9426e`
|
- CLI-Verdrahtung: `af9426e`
|
||||||
- Startskript: `141ff2e`
|
- Startskript: `141ff2e`
|
||||||
@@ -287,8 +289,8 @@ Abnahmestatus:
|
|||||||
|
|
||||||
## Nächster sinnvoller Auftrag
|
## Nächster sinnvoller Auftrag
|
||||||
|
|
||||||
Auf `feat/desktop-ui` als Nächstes Freigabedialog mit drei Ergebnissen,
|
Auf `feat/desktop-ui` als Nächstes Offscreen-GUI-Tests, sicheres Startskript,
|
||||||
Qt-Worker-Abbruch, Sitzungsbedienung und bereinigte Statusanzeige gezielt testen.
|
Desktopanleitung und anschließend echten lokalen GUI-Smoke ergänzen.
|
||||||
Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema.
|
Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema.
|
||||||
Noch keine Obsidian-Integration oder Tools beginnen.
|
Noch keine Obsidian-Integration oder Tools beginnen.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError
|
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError
|
||||||
from javis.providers.base import (
|
from javis.providers.base import (
|
||||||
@@ -18,7 +19,14 @@ from javis.providers.base import (
|
|||||||
)
|
)
|
||||||
from javis.security.privacy import CloudPolicy, PrivacyDecision, PrivacyRouter
|
from javis.security.privacy import CloudPolicy, PrivacyDecision, PrivacyRouter
|
||||||
|
|
||||||
ApprovalCallback = Callable[[PrivacyDecision], bool]
|
|
||||||
|
class ApprovalChoice(StrEnum):
|
||||||
|
ALLOW = "allow"
|
||||||
|
LOCAL = "local"
|
||||||
|
CANCEL = "cancel"
|
||||||
|
|
||||||
|
|
||||||
|
ApprovalCallback = Callable[[PrivacyDecision], ApprovalChoice | bool]
|
||||||
NoticeCallback = Callable[[str], None]
|
NoticeCallback = Callable[[str], None]
|
||||||
CloudProviderFactory = Callable[[], LocalModelProvider]
|
CloudProviderFactory = Callable[[], LocalModelProvider]
|
||||||
|
|
||||||
@@ -89,6 +97,14 @@ class HybridProvider:
|
|||||||
raise ValueError("Provider-Modus muss auto, local oder gemini sein.")
|
raise ValueError("Provider-Modus muss auto, local oder gemini sein.")
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
|
|
||||||
|
def _approval_choice(self, decision: PrivacyDecision) -> ApprovalChoice:
|
||||||
|
result = self._approval_callback(decision)
|
||||||
|
if isinstance(result, bool):
|
||||||
|
return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL
|
||||||
|
if isinstance(result, ApprovalChoice):
|
||||||
|
return result
|
||||||
|
raise ValueError("Ungültiges Ergebnis der Cloudfreigabe.")
|
||||||
|
|
||||||
def chat(self, messages: list[ChatMessage]) -> str:
|
def chat(self, messages: list[ChatMessage]) -> str:
|
||||||
current_text = messages[-1].content if messages else ""
|
current_text = messages[-1].content if messages else ""
|
||||||
cloud_requested = self.mode == "gemini"
|
cloud_requested = self.mode == "gemini"
|
||||||
@@ -110,8 +126,11 @@ class HybridProvider:
|
|||||||
|
|
||||||
approved = decision.policy is CloudPolicy.ALLOWED
|
approved = decision.policy is CloudPolicy.ALLOWED
|
||||||
if decision.policy is CloudPolicy.ASK:
|
if decision.policy is CloudPolicy.ASK:
|
||||||
approved = self._approval_callback(decision)
|
choice = self._approval_choice(decision)
|
||||||
if not approved:
|
if choice is ApprovalChoice.CANCEL:
|
||||||
|
raise ResponseAbortedError("Anfrage vor der Cloudfreigabe abgebrochen.")
|
||||||
|
approved = choice is ApprovalChoice.ALLOW
|
||||||
|
if choice is ApprovalChoice.LOCAL:
|
||||||
return self._local(messages, decision, "Cloudfreigabe abgelehnt", fallback=True)
|
return self._local(messages, decision, "Cloudfreigabe abgelehnt", fallback=True)
|
||||||
|
|
||||||
unavailable_reason = self._cloud_unavailable_reason()
|
unavailable_reason = self._cloud_unavailable_reason()
|
||||||
@@ -194,8 +213,11 @@ class HybridProvider:
|
|||||||
|
|
||||||
approved = decision.policy is CloudPolicy.ALLOWED
|
approved = decision.policy is CloudPolicy.ALLOWED
|
||||||
if decision.policy is CloudPolicy.ASK:
|
if decision.policy is CloudPolicy.ASK:
|
||||||
approved = self._approval_callback(decision)
|
choice = self._approval_choice(decision)
|
||||||
if not approved:
|
if choice is ApprovalChoice.CANCEL:
|
||||||
|
raise ResponseAbortedError("Anfrage vor der Cloudfreigabe abgebrochen.")
|
||||||
|
approved = choice is ApprovalChoice.ALLOW
|
||||||
|
if choice is ApprovalChoice.LOCAL:
|
||||||
yield from self._stream_local(
|
yield from self._stream_local(
|
||||||
messages,
|
messages,
|
||||||
decision,
|
decision,
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ class DesktopController:
|
|||||||
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
yield StreamEvent(StreamEventKind.CHUNK, text=chunk)
|
yield StreamEvent(StreamEventKind.CHUNK, text=chunk)
|
||||||
except ProviderError as exc:
|
except ProviderError as exc:
|
||||||
|
if cancel_event.is_set():
|
||||||
|
self.state = GenerationState.ABORTED
|
||||||
|
yield StreamEvent(StreamEventKind.STATE, state=self.state)
|
||||||
|
return
|
||||||
self.state = GenerationState.ERROR
|
self.state = GenerationState.ERROR
|
||||||
yield StreamEvent(StreamEventKind.ERROR, text=sanitized_error(exc), state=self.state)
|
yield StreamEvent(StreamEventKind.ERROR, text=sanitized_error(exc), state=self.state)
|
||||||
return
|
return
|
||||||
@@ -101,8 +105,11 @@ class DesktopController:
|
|||||||
def status(self) -> DesktopStatus:
|
def status(self) -> DesktopStatus:
|
||||||
runtime_status = self.runtime.status()
|
runtime_status = self.runtime.status()
|
||||||
route = self.runtime.hybrid_provider.last_route
|
route = self.runtime.hybrid_provider.last_route
|
||||||
|
state = self.state
|
||||||
|
if state is GenerationState.READY and not runtime_status.ollama_reachable:
|
||||||
|
state = GenerationState.OFFLINE
|
||||||
return DesktopStatus(
|
return DesktopStatus(
|
||||||
state=self.state,
|
state=state,
|
||||||
mode="Lokal" if runtime_status.provider_mode == "local" else "Hybrid/Auto",
|
mode="Lokal" if runtime_status.provider_mode == "local" else "Hybrid/Auto",
|
||||||
provider=route.provider if route else "—",
|
provider=route.provider if route else "—",
|
||||||
ollama_reachable=runtime_status.ollama_reachable,
|
ollama_reachable=runtime_status.ollama_reachable,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
from javis.providers.base import ChatMessage
|
from javis.providers.base import ChatMessage
|
||||||
from javis.security.privacy import PrivacyDecision
|
from javis.security.privacy import PrivacyDecision
|
||||||
from javis.ui.chat_controller import DesktopController
|
from javis.ui.chat_controller import DesktopController
|
||||||
@@ -63,7 +64,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._connect_signals()
|
self._connect_signals()
|
||||||
self._apply_style()
|
self._apply_style()
|
||||||
self._new_session()
|
self._open_initial_session()
|
||||||
self._animation = QTimer(self)
|
self._animation = QTimer(self)
|
||||||
self._animation.timeout.connect(self._animate_state)
|
self._animation.timeout.connect(self._animate_state)
|
||||||
self._animation.start(450)
|
self._animation.start(450)
|
||||||
@@ -191,6 +192,20 @@ class MainWindow(QMainWindow):
|
|||||||
self.message_edit.setFocus()
|
self.message_edit.setFocus()
|
||||||
self._refresh_status()
|
self._refresh_status()
|
||||||
|
|
||||||
|
def _open_initial_session(self) -> None:
|
||||||
|
try:
|
||||||
|
sessions = self.controller.list_sessions()
|
||||||
|
if sessions:
|
||||||
|
loaded = self.controller.load_session(sessions[0].id)
|
||||||
|
self._messages = list(loaded.messages)
|
||||||
|
self._render_chat()
|
||||||
|
self._refresh_sessions()
|
||||||
|
self._refresh_status()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(str(exc))
|
||||||
|
self._new_session()
|
||||||
|
|
||||||
def _refresh_sessions(self) -> None:
|
def _refresh_sessions(self) -> None:
|
||||||
try:
|
try:
|
||||||
sessions = self.controller.list_sessions(self.search_edit.text())
|
sessions = self.controller.list_sessions(self.search_edit.text())
|
||||||
@@ -332,8 +347,17 @@ class MainWindow(QMainWindow):
|
|||||||
box.setInformativeText(f"Lokale Einstufung: {decision.reason}")
|
box.setInformativeText(f"Lokale Einstufung: {decision.reason}")
|
||||||
allow = box.addButton("Einmal erlauben", QMessageBox.AcceptRole)
|
allow = box.addButton("Einmal erlauben", QMessageBox.AcceptRole)
|
||||||
local = box.addButton("Lokal beantworten", QMessageBox.RejectRole)
|
local = box.addButton("Lokal beantworten", QMessageBox.RejectRole)
|
||||||
|
cancel = box.addButton("Abbrechen", QMessageBox.DestructiveRole)
|
||||||
box.exec()
|
box.exec()
|
||||||
self.approval_bridge.resolve(box.clickedButton() is allow)
|
clicked = box.clickedButton()
|
||||||
|
choice = ApprovalChoice.CANCEL
|
||||||
|
if clicked is allow:
|
||||||
|
choice = ApprovalChoice.ALLOW
|
||||||
|
elif clicked is local:
|
||||||
|
choice = ApprovalChoice.LOCAL
|
||||||
|
elif clicked is cancel:
|
||||||
|
choice = ApprovalChoice.CANCEL
|
||||||
|
self.approval_bridge.resolve(choice)
|
||||||
if box.clickedButton() is local:
|
if box.clickedButton() is local:
|
||||||
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
|
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import threading
|
|||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal, Slot
|
from PySide6.QtCore import QObject, Signal, Slot
|
||||||
|
|
||||||
|
from javis.core.provider_router import ApprovalChoice
|
||||||
from javis.security.privacy import PrivacyDecision
|
from javis.security.privacy import PrivacyDecision
|
||||||
from javis.ui.chat_controller import DesktopController
|
from javis.ui.chat_controller import DesktopController
|
||||||
|
|
||||||
@@ -18,13 +19,13 @@ class ApprovalBridge(QObject):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._event: threading.Event | None = None
|
self._event: threading.Event | None = None
|
||||||
self._result = False
|
self._result = ApprovalChoice.CANCEL
|
||||||
|
|
||||||
def request(self, decision: PrivacyDecision) -> bool:
|
def request(self, decision: PrivacyDecision) -> ApprovalChoice:
|
||||||
event = threading.Event()
|
event = threading.Event()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._event = event
|
self._event = event
|
||||||
self._result = False
|
self._result = ApprovalChoice.CANCEL
|
||||||
self.approval_requested.emit(decision)
|
self.approval_requested.emit(decision)
|
||||||
event.wait()
|
event.wait()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -32,15 +33,15 @@ class ApprovalBridge(QObject):
|
|||||||
self._event = None
|
self._event = None
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def resolve(self, allowed: bool) -> None:
|
def resolve(self, choice: ApprovalChoice) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._result = allowed
|
self._result = choice
|
||||||
event = self._event
|
event = self._event
|
||||||
if event is not None:
|
if event is not None:
|
||||||
event.set()
|
event.set()
|
||||||
|
|
||||||
def cancel_pending(self) -> None:
|
def cancel_pending(self) -> None:
|
||||||
self.resolve(False)
|
self.resolve(ApprovalChoice.CANCEL)
|
||||||
|
|
||||||
|
|
||||||
class StreamWorker(QObject):
|
class StreamWorker(QObject):
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from javis.core.chat_service import ChatService
|
||||||
|
from javis.memory.sqlite_store import SQLiteSessionStore
|
||||||
from javis.providers.base import ChatMessage
|
from javis.providers.base import ChatMessage
|
||||||
from javis.ui.chat_controller import DesktopController, sanitized_error
|
from javis.ui.chat_controller import DesktopController, sanitized_error
|
||||||
|
from javis.ui.models import GenerationState, StreamEventKind
|
||||||
|
|
||||||
|
|
||||||
class _Service:
|
class _Service:
|
||||||
@@ -44,6 +50,20 @@ class _Hybrid:
|
|||||||
self.mode = mode
|
self.mode = mode
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamingProvider:
|
||||||
|
name = "ollama"
|
||||||
|
model = "test"
|
||||||
|
last_route = None
|
||||||
|
mode = "local"
|
||||||
|
|
||||||
|
def stream_chat(self, _messages):
|
||||||
|
yield "Teil "
|
||||||
|
yield "Antwort"
|
||||||
|
|
||||||
|
def set_mode(self, mode: str) -> None:
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
|
||||||
class DesktopControllerTests(unittest.TestCase):
|
class DesktopControllerTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.runtime = SimpleNamespace(
|
self.runtime = SimpleNamespace(
|
||||||
@@ -71,3 +91,43 @@ class DesktopControllerTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertNotIn(secret, result)
|
self.assertNotIn(secret, result)
|
||||||
self.assertIn("MASKIERT", result)
|
self.assertIn("MASKIERT", result)
|
||||||
|
|
||||||
|
def test_streaming_events_are_forwarded_and_completed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
provider = _StreamingProvider()
|
||||||
|
service = ChatService(
|
||||||
|
SQLiteSessionStore(Path(directory) / "sessions.sqlite3"),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
controller = DesktopController(
|
||||||
|
SimpleNamespace(service=service, hybrid_provider=provider)
|
||||||
|
)
|
||||||
|
|
||||||
|
events = list(controller.stream_message("Hallo", threading.Event()))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[event.text for event in events if event.kind is StreamEventKind.CHUNK],
|
||||||
|
["Teil ", "Antwort"],
|
||||||
|
)
|
||||||
|
self.assertEqual(events[-1].kind, StreamEventKind.FINISHED)
|
||||||
|
session = service.list_sessions()[0]
|
||||||
|
self.assertEqual(service.load_session(session.id).session.message_count, 2)
|
||||||
|
|
||||||
|
def test_cancel_drops_partial_exchange(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
provider = _StreamingProvider()
|
||||||
|
service = ChatService(
|
||||||
|
SQLiteSessionStore(Path(directory) / "sessions.sqlite3"),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
controller = DesktopController(
|
||||||
|
SimpleNamespace(service=service, hybrid_provider=provider)
|
||||||
|
)
|
||||||
|
cancel = threading.Event()
|
||||||
|
cancel.set()
|
||||||
|
|
||||||
|
events = list(controller.stream_message("Hallo", cancel))
|
||||||
|
|
||||||
|
self.assertEqual(events[-1].state, GenerationState.ABORTED)
|
||||||
|
session = service.list_sessions()[0]
|
||||||
|
self.assertEqual(service.load_session(session.id).messages, [])
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from javis.core.provider_router import HybridProvider
|
from javis.core.provider_router import ApprovalChoice, HybridProvider
|
||||||
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore
|
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore
|
||||||
from javis.providers.base import (
|
from javis.providers.base import (
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -182,6 +182,19 @@ class HybridProviderTests(unittest.TestCase):
|
|||||||
self.assertEqual(answer, "Cloud")
|
self.assertEqual(answer, "Cloud")
|
||||||
self.assertEqual(len(self.cloud.calls), 1)
|
self.assertEqual(len(self.cloud.calls), 1)
|
||||||
|
|
||||||
|
def test_ask_supports_allow_local_and_cancel_choices(self) -> None:
|
||||||
|
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
|
||||||
|
|
||||||
|
allowed = self._router(approval_callback=lambda _decision: ApprovalChoice.ALLOW)
|
||||||
|
self.assertEqual(allowed.chat(question), "Cloud")
|
||||||
|
|
||||||
|
local = self._router(approval_callback=lambda _decision: ApprovalChoice.LOCAL)
|
||||||
|
self.assertEqual(local.chat(question), "Lokal")
|
||||||
|
|
||||||
|
cancelled = self._router(approval_callback=lambda _decision: ApprovalChoice.CANCEL)
|
||||||
|
with self.assertRaises(ResponseAbortedError):
|
||||||
|
cancelled.chat(question)
|
||||||
|
|
||||||
def test_429_falls_back_without_losing_local_answer(self) -> None:
|
def test_429_falls_back_without_losing_local_answer(self) -> None:
|
||||||
self.cloud.error = ProviderRateLimitError("quota")
|
self.cloud.error = ProviderRateLimitError("quota")
|
||||||
router = self._router()
|
router = self._router()
|
||||||
|
|||||||
Reference in New Issue
Block a user