Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
This commit is contained in:
228
src/desktop_app/diary_dialog.py
Normal file
228
src/desktop_app/diary_dialog.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Diary update dialog shown during shutdown."""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Optional, List
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLabel, QTextEdit, QProgressBar, QFrame
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from .themes import JARVIS_THEME_STYLESHEET, COLORS
|
||||
|
||||
# IPC protocol prefix - must match daemon.py
|
||||
DIARY_IPC_PREFIX = "__DIARY__:"
|
||||
|
||||
|
||||
class DiarySignals(QObject):
|
||||
"""Signals for diary update progress."""
|
||||
# Emitted when a new token is received from LLM
|
||||
token_received = pyqtSignal(str)
|
||||
# Emitted when status changes (e.g., "Analyzing conversations...")
|
||||
status_changed = pyqtSignal(str)
|
||||
# Emitted when conversation chunks are available
|
||||
chunks_received = pyqtSignal(list)
|
||||
# Emitted when the diary update completes
|
||||
completed = pyqtSignal(bool) # True = success, False = failed/skipped
|
||||
|
||||
|
||||
class DiaryUpdateDialog(QDialog):
|
||||
"""
|
||||
Dialog shown during shutdown diary update.
|
||||
|
||||
Shows:
|
||||
- The conversation chunks being processed
|
||||
- Live streaming of the diary entry being written
|
||||
- Progress indication
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.signals = DiarySignals()
|
||||
self._setup_ui()
|
||||
self._connect_signals()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the dialog UI."""
|
||||
self.setWindowTitle("Saving Your Diary")
|
||||
self.setMinimumSize(550, 450)
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Dialog |
|
||||
Qt.WindowType.CustomizeWindowHint |
|
||||
Qt.WindowType.WindowTitleHint
|
||||
)
|
||||
|
||||
# Apply the shared Jarvis theme
|
||||
self.setStyleSheet(JARVIS_THEME_STYLESHEET)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
|
||||
# Title
|
||||
title = QLabel("Updating Your Diary")
|
||||
title.setObjectName("title")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# Status label
|
||||
self.status_label = QLabel("Preparing to save...")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setObjectName("subtitle")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Progress bar (indeterminate)
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setRange(0, 0) # Indeterminate
|
||||
self.progress_bar.setTextVisible(False)
|
||||
self.progress_bar.setFixedHeight(6)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Conversations section
|
||||
conv_label = QLabel("Today's Conversations")
|
||||
conv_label.setObjectName("section_title")
|
||||
layout.addWidget(conv_label)
|
||||
|
||||
self.conversations_text = QTextEdit()
|
||||
self.conversations_text.setReadOnly(True)
|
||||
self.conversations_text.setMaximumHeight(100)
|
||||
self.conversations_text.setPlaceholderText("Loading conversations...")
|
||||
layout.addWidget(self.conversations_text)
|
||||
|
||||
# Diary entry section
|
||||
diary_label = QLabel("Diary Entry")
|
||||
diary_label.setObjectName("section_title")
|
||||
layout.addWidget(diary_label)
|
||||
|
||||
self.diary_text = QTextEdit()
|
||||
self.diary_text.setReadOnly(True)
|
||||
self.diary_text.setPlaceholderText("Writing diary entry...")
|
||||
layout.addWidget(self.diary_text, stretch=1)
|
||||
|
||||
# Hint at bottom
|
||||
hint = QLabel("Please wait while Jarvis saves your conversations...")
|
||||
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
hint.setObjectName("subtitle")
|
||||
layout.addWidget(hint)
|
||||
|
||||
def _connect_signals(self):
|
||||
"""Connect internal signals."""
|
||||
self.signals.token_received.connect(self._on_token)
|
||||
self.signals.status_changed.connect(self._on_status_changed)
|
||||
self.signals.chunks_received.connect(self._on_chunks_received)
|
||||
self.signals.completed.connect(self._on_completed)
|
||||
|
||||
def _on_chunks_received(self, chunks: list):
|
||||
"""Handle receiving conversation chunks."""
|
||||
self.set_conversations(chunks)
|
||||
|
||||
def _on_token(self, token: str):
|
||||
"""Handle receiving a token from the LLM."""
|
||||
# Append token to diary text
|
||||
cursor = self.diary_text.textCursor()
|
||||
cursor.movePosition(cursor.MoveOperation.End)
|
||||
cursor.insertText(token)
|
||||
self.diary_text.setTextCursor(cursor)
|
||||
# Auto-scroll to bottom
|
||||
scrollbar = self.diary_text.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
def _on_status_changed(self, status: str):
|
||||
"""Handle status change."""
|
||||
self.status_label.setText(status)
|
||||
|
||||
def _on_completed(self, success: bool):
|
||||
"""Handle completion."""
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(100)
|
||||
if success:
|
||||
self.status_label.setText("Diary saved successfully!")
|
||||
self.status_label.setStyleSheet(f"color: {COLORS['success']};")
|
||||
else:
|
||||
self.status_label.setText("No new entries to save")
|
||||
self.status_label.setStyleSheet(f"color: {COLORS['text_muted']};")
|
||||
# Clear placeholders if nothing was populated
|
||||
if not self.conversations_text.toPlainText():
|
||||
self.conversations_text.setPlainText("(No conversations to save)")
|
||||
if not self.diary_text.toPlainText():
|
||||
self.diary_text.setPlainText("(Nothing to write)")
|
||||
|
||||
def set_conversations(self, chunks: List[str]):
|
||||
"""Set the conversation chunks being processed."""
|
||||
if not chunks:
|
||||
self.conversations_text.setPlainText("(No conversations to save)")
|
||||
return
|
||||
|
||||
# Format chunks nicely
|
||||
formatted = []
|
||||
for i, chunk in enumerate(chunks[-5:], 1): # Show last 5 chunks
|
||||
# Truncate long chunks
|
||||
preview = chunk[:200] + "..." if len(chunk) > 200 else chunk
|
||||
# Clean up whitespace
|
||||
preview = " ".join(preview.split())
|
||||
formatted.append(f"{i}. {preview}")
|
||||
|
||||
self.conversations_text.setPlainText("\n\n".join(formatted))
|
||||
|
||||
def set_diary_content(self, content: str):
|
||||
"""Set the diary content (for non-streaming updates)."""
|
||||
self.diary_text.setPlainText(content)
|
||||
|
||||
def append_diary_token(self, token: str):
|
||||
"""Append a token to the diary content (for streaming)."""
|
||||
self.signals.token_received.emit(token)
|
||||
|
||||
def set_status(self, status: str):
|
||||
"""Update the status message."""
|
||||
self.signals.status_changed.emit(status)
|
||||
|
||||
def mark_completed(self, success: bool = True):
|
||||
"""Mark the update as completed."""
|
||||
self.signals.completed.emit(success)
|
||||
|
||||
def process_log_line(self, line: str) -> bool:
|
||||
"""
|
||||
Process a log line, checking if it contains an IPC event.
|
||||
|
||||
Used in subprocess mode where the daemon emits diary events via stdout.
|
||||
|
||||
Args:
|
||||
line: A log line from the daemon
|
||||
|
||||
Returns:
|
||||
True if the line was an IPC event and was processed, False otherwise
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line.startswith(DIARY_IPC_PREFIX):
|
||||
return False
|
||||
|
||||
try:
|
||||
import json
|
||||
json_str = line[len(DIARY_IPC_PREFIX):]
|
||||
event = json.loads(json_str)
|
||||
event_type = event.get("type")
|
||||
data = event.get("data")
|
||||
|
||||
if event_type == "chunks":
|
||||
self.signals.chunks_received.emit(data)
|
||||
elif event_type == "token":
|
||||
self.signals.token_received.emit(data)
|
||||
elif event_type == "status":
|
||||
self.signals.status_changed.emit(data)
|
||||
elif event_type == "complete":
|
||||
self.signals.completed.emit(data)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def set_subprocess_mode(self):
|
||||
"""
|
||||
Configure dialog for subprocess mode.
|
||||
|
||||
In subprocess mode, the daemon emits IPC events via stdout which are
|
||||
intercepted and forwarded to this dialog via process_log_line().
|
||||
"""
|
||||
# Initial state - will be updated when IPC events arrive
|
||||
self.conversations_text.setPlaceholderText("Waiting for daemon...")
|
||||
self.diary_text.setPlaceholderText("Waiting for diary generation...")
|
||||
Reference in New Issue
Block a user