Files
air-agent-framework/gui/chat_gui.py
T

403 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Air Agent GUI —— Fluent Design 聊天界面 (PyQt5)
"""
import sys
import os
os.environ.setdefault("QT_API", "PyQt5")
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QSizePolicy, QSpacerItem, QScrollBar,
)
from PyQt5.QtGui import QFont
from qfluentwidgets import (
FluentWindow, NavigationItemPosition, ScrollArea,
PushButton, PrimaryPushButton,
LineEdit,
BodyLabel, TitleLabel, CaptionLabel, SubtitleLabel, StrongBodyLabel,
FluentIcon as FIF,
CardWidget,
setTheme, Theme,
InfoBar,
SwitchButton, SpinBox, ComboBox,
)
from config.settings import AppSettings
from gui.agent_thread import AgentThread
MSG_COLORS = {
"user": {"bg": "#e8f0fe", "text": "#1a1a1a"},
"assistant": {"bg": "#f0f0f0", "text": "#1a1a1a"},
"interrupt": {"bg": "#fff8e1", "text": "#8d6e00"},
}
DARK_COLORS = {
"user": {"bg": "#2b3a4a", "text": "#e0e0e0"},
"assistant": {"bg": "#2d2d2d", "text": "#e0e0e0"},
"interrupt": {"bg": "#3d3510", "text": "#ffd54f"},
}
class ChatBubble(CardWidget):
def __init__(self, text: str, role: str, parent=None):
super().__init__(parent)
self.role = role
self.setBorderRadius(12)
self.setMinimumHeight(40)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 10, 16, 10)
layout.setSpacing(4)
label = BodyLabel(text, self)
label.setWordWrap(True)
label.setMinimumWidth(100)
label.setMaximumWidth(520)
label.setFont(QFont("Segoe UI", 10))
self._label = label
layout.addWidget(label)
def apply_theme(self, is_dark: bool):
colors = DARK_COLORS if is_dark else MSG_COLORS
c = colors.get(self.role, colors["assistant"])
self.setStyleSheet(f"""
ChatBubble {{
background-color: {c["bg"]};
border-radius: 12px;
}}
""")
self._label.setStyleSheet(f"color: {c['text']};")
class ChatPage(QWidget):
def __init__(self, agent_thread: AgentThread, parent=None):
super().__init__(parent)
self.setObjectName("chatPage")
self.agent = agent_thread
self._bubbles = []
self._is_streaming = False
self._stream_bubble = None
self._is_dark = False
self._typing_timer = QTimer(self)
self._typing_timer.setSingleShot(True)
self._typing_timer.timeout.connect(self._on_typing_pause)
self._last_input = ""
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
tb = QWidget(self)
tb.setFixedHeight(48)
tbh = QHBoxLayout(tb)
tbh.setContentsMargins(24, 8, 24, 8)
title = TitleLabel("与 AI 聊天", tb)
title.setFont(QFont("Segoe UI", 14, QFont.Weight.SemiBold if hasattr(QFont.Weight, 'SemiBold') else 63))
tbh.addWidget(title)
tbh.addStretch()
root.addWidget(tb)
self._scroll = ScrollArea(self)
self._scroll.setWidgetResizable(True)
self._scroll.setObjectName("chatScroll")
try:
self._scroll.enableTransparentBackground()
except AttributeError:
pass
self._msg_box = QWidget()
self._msg_box.setObjectName("msgBox")
self._msg_layout = QVBoxLayout(self._msg_box)
self._msg_layout.setContentsMargins(24, 8, 24, 8)
self._msg_layout.setSpacing(8)
self._msg_layout.setAlignment(Qt.AlignTop)
spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
self._msg_layout.addSpacerItem(spacer)
self._scroll.setWidget(self._msg_box)
root.addWidget(self._scroll, stretch=1)
input_area = QWidget(self)
input_area.setFixedHeight(80)
input_layout = QHBoxLayout(input_area)
input_layout.setContentsMargins(24, 12, 24, 12)
input_layout.setSpacing(8)
self._input_edit = LineEdit(input_area)
self._input_edit.setPlaceholderText("说点什么...")
self._input_edit.setClearButtonEnabled(True)
self._input_edit.setMinimumHeight(36)
self._input_edit.returnPressed.connect(self._send_message)
self._input_edit.textChanged.connect(self._on_text_changed)
self._send_btn = PrimaryPushButton(FIF.SEND, "发送", input_area)
self._send_btn.setFixedHeight(36)
self._send_btn.clicked.connect(self._send_message)
self._indicator = BodyLabel("", input_area)
self._indicator.setFixedWidth(80)
self._indicator.setAlignment(Qt.AlignCenter)
input_layout.addWidget(self._input_edit, stretch=1)
input_layout.addWidget(self._send_btn)
input_layout.addWidget(self._indicator)
root.addWidget(input_area)
self.agent.message_chunk.connect(self._on_chunk)
self.agent.message_done.connect(self._on_message_done)
self.agent.agent_thinking.connect(self._on_thinking)
self.agent.agent_idle.connect(self._on_idle)
self.agent.error_occurred.connect(self._on_error)
self.agent.interrupt_signal.connect(self._on_interrupt)
self.agent.idle_topic_signal.connect(self._on_idle_topic)
def _on_text_changed(self, text: str):
if text and text != self._last_input:
self._last_input = text
self._typing_timer.start(2000)
elif not text:
self._last_input = ""
def _on_typing_pause(self):
text = self._input_edit.text()
if len(text) > 3:
self.agent.check_interruption(text, 2.0)
def _send_message(self):
text = self._input_edit.text().strip()
if not text or self._is_streaming:
return
self._input_edit.clear()
self._typing_timer.stop()
self._last_input = ""
self._add_bubble(text, "user")
self.agent.send_message(text)
def _add_bubble(self, text: str, role: str):
bubble = ChatBubble(text, role, self._msg_box)
bubble.apply_theme(self._is_dark)
row = QHBoxLayout()
if role == "user":
row.addStretch()
row.addWidget(bubble)
else:
row.addWidget(bubble)
row.addStretch()
self._msg_layout.insertLayout(self._msg_layout.count() - 1, row)
self._bubbles.append(bubble)
self._scroll_to_bottom()
def _scroll_to_bottom(self):
QTimer.singleShot(50, lambda: self._scroll.verticalScrollBar().setValue(
self._scroll.verticalScrollBar().maximum()
))
def _on_chunk(self, chunk: str):
if not self._is_streaming:
self._is_streaming = True
self._indicator.setText("输入中...")
bubble = ChatBubble("", "assistant", self._msg_box)
bubble.apply_theme(self._is_dark)
row = QHBoxLayout()
row.addWidget(bubble)
row.addStretch()
self._msg_layout.insertLayout(self._msg_layout.count() - 1, row)
self._stream_bubble = bubble
self._bubbles.append(bubble)
if self._stream_bubble:
current = self._stream_bubble._label.text()
self._stream_bubble._label.setText(current + chunk)
self._scroll_to_bottom()
def _on_message_done(self, full: str):
self._is_streaming = False
self._stream_bubble = None
self._indicator.setText("")
def _on_thinking(self):
self._indicator.setText("思考中...")
def _on_idle(self):
self._indicator.setText("")
def _on_error(self, msg: str):
self._indicator.setText("")
InfoBar.error("错误", msg, duration=5000, parent=self.window())
def _on_interrupt(self, response: str, style: str):
text = f"[插话] {response}"
self._add_bubble(text, "interrupt")
def _on_idle_topic(self, suggestion: str):
text = f"[主动] {suggestion}"
self._add_bubble(text, "interrupt")
def set_theme(self, dark: bool):
self._is_dark = dark
for b in self._bubbles:
b.apply_theme(dark)
class SettingsPage(QWidget):
def __init__(self, config: AppSettings, parent=None):
super().__init__(parent)
self.setObjectName("settingsPage")
self.config = config
scroll = ScrollArea(self)
scroll.setWidgetResizable(True)
try:
scroll.enableTransparentBackground()
except AttributeError:
pass
inner = QWidget()
vl = QVBoxLayout(inner)
vl.setContentsMargins(36, 24, 36, 24)
vl.setSpacing(16)
vl.addWidget(TitleLabel("设置", inner))
vl.addSpacing(8)
vl.addWidget(SubtitleLabel("API 配置"))
api_card = CardWidget(inner)
ac = QVBoxLayout(api_card)
ac.setContentsMargins(20, 14, 20, 14)
ac.setSpacing(8)
ac.addWidget(BodyLabel("API Key:"))
self.key_edit = LineEdit(api_card)
self.key_edit.setText(config.llm.api_key)
self.key_edit.setEchoMode(2)
self.key_edit.setClearButtonEnabled(True)
ac.addWidget(self.key_edit)
ac.addWidget(BodyLabel("Base URL:"))
self.url_edit = LineEdit(api_card)
self.url_edit.setText(config.llm.base_url)
self.url_edit.setClearButtonEnabled(True)
ac.addWidget(self.url_edit)
ac.addWidget(BodyLabel("Model:"))
self.model_edit = LineEdit(api_card)
self.model_edit.setText(config.llm.model)
self.model_edit.setClearButtonEnabled(True)
ac.addWidget(self.model_edit)
apply_api_btn = PushButton("保存 API 设置", api_card)
apply_api_btn.clicked.connect(self._save_api)
ac.addWidget(apply_api_btn)
vl.addWidget(api_card)
vl.addWidget(SubtitleLabel("对话设置"))
chat_card = CardWidget(inner)
cc = QVBoxLayout(chat_card)
cc.setContentsMargins(20, 14, 20, 14)
cc.setSpacing(8)
cc.addWidget(BodyLabel("AI 名字:"))
self.name_edit = LineEdit(chat_card)
self.name_edit.setText(config.personality.name)
cc.addWidget(self.name_edit)
cc.addWidget(BodyLabel("对话风格:"))
self.style_combo = ComboBox(chat_card)
self.style_combo.addItems(["casual", "friendly", "professional"])
idx = self.style_combo.findText(config.personality.style)
if idx >= 0:
self.style_combo.setCurrentIndex(idx)
cc.addWidget(self.style_combo)
cc.addWidget(BodyLabel("空闲主动聊天:"))
self.proactive_switch = SwitchButton(chat_card)
self.proactive_switch.setChecked(config.personality.proactive_chat)
cc.addWidget(self.proactive_switch)
apply_chat_btn = PushButton("保存对话设置", chat_card)
apply_chat_btn.clicked.connect(self._save_chat)
cc.addWidget(apply_chat_btn)
vl.addWidget(chat_card)
vl.addWidget(SubtitleLabel("外观"))
theme_card = CardWidget(inner)
tl = QHBoxLayout(theme_card)
tl.setContentsMargins(20, 14, 20, 14)
tl.setSpacing(12)
tl.addWidget(BodyLabel("深色模式:"))
self.theme_switch = SwitchButton(theme_card)
self.theme_switch.checkedChanged.connect(self._toggle_theme)
tl.addWidget(self.theme_switch)
tl.addStretch()
vl.addWidget(theme_card)
vl.addStretch()
scroll.setWidget(inner)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.addWidget(scroll)
def _save_api(self):
self.config.llm.api_key = self.key_edit.text().strip()
self.config.llm.base_url = self.url_edit.text().strip()
self.config.llm.model = self.model_edit.text().strip()
self.config.save()
InfoBar.success("已保存", "API 配置已保存,重启后生效", duration=3000, parent=self.window())
def _save_chat(self):
self.config.personality.name = self.name_edit.text().strip()
self.config.personality.style = self.style_combo.currentText()
self.config.personality.proactive_chat = self.proactive_switch.isChecked()
self.config.save()
InfoBar.success("已保存", "对话设置已保存,重启后生效", duration=3000, parent=self.window())
def _toggle_theme(self, dark: bool):
setTheme(Theme.DARK if dark else Theme.LIGHT)
main = self.window()
if hasattr(main, "chat_page"):
main.chat_page.set_theme(dark)
class AgentWindow(FluentWindow):
def __init__(self, config: AppSettings):
super().__init__()
self.config = config
self.setWindowTitle("Air Agent")
self.setMinimumSize(800, 560)
self.resize(1000, 680)
self.agent_thread = AgentThread(config)
self.chat_page = ChatPage(self.agent_thread, self)
self.settings_page = SettingsPage(config, self)
self.addSubInterface(
self.chat_page, FIF.CHAT, "聊天", NavigationItemPosition.TOP
)
self.addSubInterface(
self.settings_page, FIF.SETTING, "设置", NavigationItemPosition.BOTTOM
)
setTheme(Theme.LIGHT)
self.agent_thread.start()
def closeEvent(self, event):
self.agent_thread.stop_agent()
self.agent_thread.quit()
self.agent_thread.wait(3000)
super().closeEvent(event)
def run_gui(config: AppSettings):
app = QApplication(sys.argv)
app.setApplicationName("Air Agent")
win = AgentWindow(config)
win.show()
sys.exit(app.exec())