403 lines
13 KiB
Python
403 lines
13 KiB
Python
#!/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())
|