Как сделать «пузырьки» для сообщений в PyQt5 QTextBrowser (как в ChatGPT)?
У меня есть простое окно чата на PyQt5, где я добавляю сообщения так:
def append_message(self, sender, message, align_right=False):
cursor = self.output.textCursor()
cursor.movePosition(QTextCursor.End)
align = "right" if align_right else "left"
# 1️⃣ Удаляем все переводы строки, чтобы не образовывались <p> абзацы
txt = message.replace("\n", " ").strip()
# 2️⃣ Если строка уже содержит HTML (например, <a>…), вставляем как есть
if re.search(r'<a\s+href=', txt, flags=re.IGNORECASE):
# содержит ссылку — вставляем как HTML (не экранируем)
inner = txt
else:
escaped = html.escape(txt)
inner = f'<span align="{align}" style="display:inline">{escaped}</span>'
html_block = f'''
<div style="margin: 4px 0;">
<p align="{align}" style="margin: 0;"><b>{html.escape(sender)}:</b></p>
{inner}
</div>
'''
print("DEBUG HTML:", html_block[:200].replace("\n", " ")) # отладка
self.output.insertHtml(html_block)
self.output.append("") # сброс Qt-форматирования
self.output.verticalScrollBar().setValue(self.output.verticalScrollBar().maximum())
Но текст получается как простые , без оформления в виде блоков-пузырьков (chat bubbles) и без обводки/фона, как в интерфейсе ChatGPT. Надо чтобы каждый фрагмент сообщения был заключён в контейнер с:
- закруглённым фоном,
- отступами,
- ограничением максимальной ширины (например, 70% окна),
- выравниванием вправо (для пользователя) или влево (для бота).
Вот весь код:
import sys, os, importlib.util, asyncio, threading, socket
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QTextEdit, QLineEdit
from PyQt5.QtGui import QTextCursor, QFont
from PyQt5.QtCore import Qt
import pyttsx3
import edge_tts
import re
import html
from PyQt5.QtWidgets import QTextBrowser
from html.parser import HTMLParser
import markdown
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QTextEdit, QLineEdit, QPushButton, QHBoxLayout
from PyQt5.QtCore import QPropertyAnimation, QRect
from concurrent.futures import ThreadPoolExecutor
from PyQt5.QtCore import QTimer
from PyQt5.QtCore import pyqtSignal, Qt, QObject, QTimer
from PyQt5.QtWidgets import QWidget
import ctypes
import sys
from PyQt5.QtWidgets import QScrollArea, QFrame
# --- Фикс playsound для Windows ---
if sys.platform == "win32":
# Подменим internal функцию
from playsound import _playsoundWin as _orig_psWin
def _fixed_playsoundWin(sound, block=True):
alias = f"pysnd_{threading.get_ident():x}"
# Win API декодирует строку как ANSI (CP‑1251), а не UTF‑8
def wcmd(cmd):
buf = ctypes.create_unicode_buffer(512)
rc = ctypes.windll.winmm.mciSendStringW(cmd, buf, len(buf), 0)
if rc:
errbuf = ctypes.create_unicode_buffer(512)
ctypes.windll.winmm.mciGetErrorStringW(rc, errbuf, len(errbuf))
msg = errbuf.value
raise Exception(f"MCI error: {rc} – {msg}")
return buf.value
wcmd(f'open "{sound}" alias {alias}')
wcmd(f"play {alias}" + (" wait" if block else ""))
# Применить патч
import playsound; playsound._playsoundWin = _fixed_playsoundWin
from playsound import playsound
def markdown_to_html(md_text: str) -> str:
return markdown.markdown(md_text, extensions=["fenced_code", "tables"])
def fix_p_align(html_body: str, align: str) -> str:
"""Добавляет align="..." к каждому <p> (если его нет)"""
return re.sub(
r'<p(?![^>]*\balign=)(.*?)>',
rf'<p\1 align="{align}" style="margin-left:0px;margin:0">',
html_body
)
class HTMLStripper(HTMLParser):
def __init__(self):
super().__init__()
self.result = []
def handle_data(self, d):
self.result.append(d)
def get_data(self):
return ''.join(self.result)
def strip_html(text: str) -> str:
stripper = HTMLStripper()
stripper.feed(text)
return stripper.get_data().strip()
def clean_for_tts(text: str) -> str:
return re.sub(r"```.*?```", "", text, flags=re.DOTALL).strip()
def remove_links(text: str) -> str:
return re.sub(r'https?://\S+', '', text).strip()
def strip_emojis(text: str) -> str:
emoji_pattern = re.compile(
"["u"\U0001F600-\U0001F64F" # эмодзи-смайлы
u"\U0001F300-\U0001F5FF" # символы и пиктограммы
u"\U0001F680-\U0001F6FF" # транспорт
u"\U0001F1E0-\U0001F1FF" # флаги
u"\u2600-\u26FF" # разное (например, ☀️)
u"\u2700-\u27BF" # стрелки и символы
"]+", flags=re.UNICODE)
return emoji_pattern.sub(r'', text)
CACHE_DIR = os.path.join(os.getenv("APPDATA") or os.getenv("LOCALAPPDATA"), "DerNeX")
CACHE_DIR = str(CACHE_DIR) # в ASCII
VOICE = "ru-RU-DmitryNeural" # Мужской голос
RATE = "+0%"
VOLUME = "+50%"
def has_internet(host="8.8.8.8", port=53, timeout=2):
try:
socket.setdefaulttimeout(timeout)
socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port))
return True
except Exception:
return False
def _play_with_playsound(path: str):
try:
playsound(path, block=False)
except Exception as e:
print(f"[DerNeX]: Ошибка при playsound — {e}")
fallback_tts(current_tts_text)
async def speak_async(text: str, rewrite: bool = False):
global current_tts_text
current_tts_text = strip_html(strip_emojis(remove_links(clean_for_tts(text))))
print(f"[DerNeX]: {current_tts_text}")
os.makedirs(CACHE_DIR, exist_ok=True)
fname = f"{abs(hash(text))}.mp3"
fpath = os.path.join(CACHE_DIR, fname)
fname = f"{abs(hash(text))}.mp3"
print(f"[DEBUG]: Путь к аудиофайлу: {fpath}")
if has_internet():
if rewrite or not os.path.exists(fpath):
try:
if os.path.exists(fpath):
os.remove(fpath)
current_tts_text = strip_html(strip_emojis(remove_links(clean_for_tts(text))))
if not current_tts_text.strip():
print("[DerNeX]: Пустой текст для озвучки — пропускаю.")
return
await edge_tts.Communicate(
text=text, voice=VOICE, rate=RATE, volume=VOLUME
).save(fpath)
except Exception as e:
print(f"[DerNeX]: ⚠️ Ошибка TTS — {e}")
fallback_tts(current_tts_text)
return
# 🟢 Замена pygame на playsound
threading.Thread(
target=lambda: _play_with_playsound(fpath),
daemon=True,
).start()
else:
fallback_tts(current_tts_text)
def fallback_tts(text: str):
def _speak():
try:
engine = pyttsx3.init()
for voice in engine.getProperty('voices'):
if "russian" in voice.name.lower() or "русский" in voice.name.lower():
engine.setProperty('voice', voice.id)
break
engine.setProperty('volume', 1.0)
engine.say(text)
engine.runAndWait()
except Exception as e:
print(f"[DerNeX]: ⚠️ Offline TTS ошибка: {e}")
threading.Thread(target=_speak, daemon=True).start()
def speak(text: str, rewrite: bool = False):
asyncio.run(speak_async(text, rewrite))
# Загрузка модулей из /modules
modules = {}
def load_modules():
modules.clear()
mod_dir = "modules"
for file in os.listdir(mod_dir):
if file.endswith(".py"):
mod_name = file[:-3]
path = os.path.join(mod_dir, file)
spec = importlib.util.spec_from_file_location(mod_name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
modules[mod_name] = module
print(f"[Загружен модуль]: {mod_name}") # <-- Добавь это
def process_command(cmd: str) -> str:
for mod in modules.values():
if hasattr(mod, "handle"):
try:
result = mod.handle(cmd)
if result is not None:
return result
except Exception as e:
return f"Ошибка в модуле {mod.__name__}: {e}"
return "Команда не распознана."
# Qt интерфейс
class DerNeXWindow(QWidget):
resultReady = pyqtSignal(str)
def __init__(self):
super().__init__()
self.executor = ThreadPoolExecutor(max_workers=2)
self.setWindowTitle("DerNeX Chat")
self.resize(600, 500)
self.setStyleSheet("""
QWidget {
background-color: #1e1e1e;
color: #f0f0f0;
font-family: Consolas, monospace;
font-size: 14px;
}
QTextEdit {
background-color: #252526;
border: 1px solid #3c3c3c;
border-radius: 8px;
padding: 8px;
}
QLineEdit {
background-color: #333333;
border: 1px solid #444;
border-radius: 8px;
padding: 6px;
margin-top: 8px;
}
QPushButton {
background-color: #333333;
border: 1px solid #444;
border-radius: 8px;
padding: 6px;
margin-top: 8px;
}
""")
layout = QVBoxLayout()
self.output = QTextBrowser(readOnly=True)
self.output.setFont(QFont("Consolas", 12))
self.output.setWordWrapMode(True)
self.output.setOpenExternalLinks(True)
self.output.setTextInteractionFlags(Qt.TextBrowserInteraction)
input_layout = QHBoxLayout()
self.input = QLineEdit(placeholderText="Введите команду и нажмите Enter")
self.input.setFont(QFont("Consolas", 12))
send_button = QPushButton("Отправить")
send_button.setFixedWidth(100)
send_button.clicked.connect(lambda: self.on_button_pressed(send_button))
input_layout.addWidget(self.input)
input_layout.addWidget(send_button)
layout.addWidget(self.output)
layout.addLayout(input_layout)
self.setLayout(layout)
self.input.returnPressed.connect(self.handle_command)
self.resultReady.connect(self._append_and_speak, Qt.QueuedConnection)
def run_command_async(self):
self.executor.submit(self.handle_command)
def on_button_pressed(self, button):
self.animate_button_click(button)
QTimer.singleShot(10, self.run_command_async)
def animate_button_click(self, button):
rect = button.geometry()
anim = QPropertyAnimation(button, b"geometry")
anim.setDuration(250)
anim.setStartValue(rect)
anim.setKeyValueAt(0.5, QRect(rect.x() + 2, rect.y() + 2, rect.width() - 4, rect.height() - 4))
anim.setEndValue(rect)
anim.start()
self.anim = anim # сохранить ссылку, чтобы GC не удалил
def handle_command(self):
cmd = self.input.text().strip()
if not cmd:
return
self.input.clear()
self.append_message("Вы", cmd, align_right=False)
# запуск долгой обработки в другом потоке
self.executor.submit(self._run_background, cmd)
def _run_background(self, cmd):
rsp = process_command(cmd) # любой модуль, который возвращает str
# НЕ используем QTimer.singleShot тут
self.resultReady.emit(rsp)
def _append_and_speak(self, response: str):
# Этот метод уже в GUI-потоке — безопасно
self.append_message("DerNeX", response, align_right=True)
tts_text = strip_html(strip_emojis(remove_links(clean_for_tts(response))))
speak(tts_text)
def append_message(self, sender, message, align_right=False):
cursor = self.output.textCursor()
cursor.movePosition(QTextCursor.End)
align = "right" if align_right else "left"
# 1️⃣ Удаляем все переводы строки, чтобы не образовывались <p> абзацы
txt = message.replace("\n", " ").strip()
# 2️⃣ Если строка уже содержит HTML (например, <a>…), вставляем как есть
if re.search(r'<a\s+href=', txt, flags=re.IGNORECASE):
# содержит ссылку — вставляем как HTML (не экранируем)
inner = txt
else:
escaped = html.escape(txt)
inner = f'<span align="{align}" style="display:inline">{escaped}</span>'
html_block = f'''
<div style="margin: 4px 0;">
<p align="{align}" style="margin: 0;"><b>{html.escape(sender)}:</b></p>
{inner}
</div>
'''
print("DEBUG HTML:", html_block[:200].replace("\n", " ")) # отладка
self.output.insertHtml(html_block)
self.output.append("") # сброс Qt-форматирования
self.output.verticalScrollBar().setValue(self.output.verticalScrollBar().maximum())
if __name__ == "__main__":
load_modules()
app = QApplication(sys.argv)
window = DerNeXWindow()
window.show()
sys.exit(app.exec_())
И пожалуйста помогите только те кто знают что делать. Заранее спасибо.
Источник: Stack Overflow на русском