Initial commit: Air Agent Framework v2:全双工对话智能体(打断、插话、主动开口)
This commit is contained in:
+402
@@ -0,0 +1,402 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user