Initial commit: Air Agent Framework v2:全双工对话智能体(打断、插话、主动开口)

This commit is contained in:
WpyQwq
2026-09-19 12:09:51 +08:00
commit fa0cb5824d
26 changed files with 2362 additions and 0 deletions
+402
View File
@@ -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())