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
View File
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import asyncio
import sys
from PyQt5.QtCore import QThread, pyqtSignal
from agent.kernel import EventBus, Event, EventType, AgentRuntime
from agent.llm import LLMClient
from agent.personality import Personality
from agent.conversation import ConversationManager
from agent.interruption import InterruptionEngine
from agent.thinker import Thinker
from agent.orchestrator import Orchestrator
from agent.tools.registry import ToolRegistry
from agent.tools.file_tools import register_file_tools
from agent.tools.bash_tools import register_bash_tools
from agent.tools.ask import register_ask_tools
from config.settings import AppSettings
class AgentThread(QThread):
message_chunk = pyqtSignal(str)
message_done = pyqtSignal(str)
agent_thinking = pyqtSignal()
agent_idle = pyqtSignal()
error_occurred = pyqtSignal(str)
interrupt_signal = pyqtSignal(str, str)
idle_topic_signal = pyqtSignal(str)
def __init__(self, config: AppSettings):
super().__init__()
self.config = config
self._loop: asyncio.AbstractEventLoop = None
self._running = False
self._input_queue: asyncio.Queue[str] = None
def run(self):
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._run_agent())
async def _run_agent(self):
self._input_queue = asyncio.Queue()
self._running = True
runtime = AgentRuntime(self.config)
llm = LLMClient(self.config.llm)
bus = runtime.bus
personality = Personality.from_config({
"name": self.config.personality.name,
"style": self.config.personality.style,
"proactive_chat": self.config.personality.proactive_chat,
"idle_timeout": self.config.personality.idle_timeout,
"humor_enabled": self.config.personality.humor_enabled,
"empathy_enabled": self.config.personality.empathy_enabled,
})
conversation = ConversationManager()
interruption = InterruptionEngine(llm=llm, config=self.config)
thinker = Thinker(bus, llm, conversation, self.config)
orchestrator = Orchestrator(runtime, llm, personality, conversation, interruption, thinker)
conversation.create_topic("general")
await runtime.start()
thinker_task = runtime.create_task(thinker.run())
async def on_thought(event: Event):
data = event.data
if data.get("type") == "idle_chat":
self.idle_topic_signal.emit(data.get("suggestion", ""))
bus.on(EventType.AGENT_THOUGHT, on_thought)
try:
while self._running:
try:
user_text = await asyncio.wait_for(
self._input_queue.get(), timeout=0.5
)
except asyncio.TimeoutError:
continue
thinker.notify_user_activity()
conversation.add_message("user", user_text)
self.agent_thinking.emit()
system_prompt = personality.build_system_prompt()
messages = [{"role": "system", "content": system_prompt}]
messages.extend(conversation.get_context_window(20))
try:
full = ""
async for chunk in llm.chat_stream_iter(messages):
full += chunk
self.message_chunk.emit(chunk)
self.message_done.emit(full)
if full.strip():
conversation.add_message("assistant", full)
except Exception as e:
self.error_occurred.emit(str(e))
self.agent_idle.emit()
finally:
await runtime.stop()
await llm.close()
def send_message(self, text: str):
if self._input_queue and self._running:
asyncio.run_coroutine_threadsafe(
self._input_queue.put(text), self._loop
)
def check_interruption(self, partial_text: str, pause: float):
if not self._running or not self._loop:
return
config = self.config
llm = LLMClient(config.llm)
engine = InterruptionEngine(llm=llm, config=config)
async def _check():
decision = await engine.evaluate(partial_text, pause, False)
if decision.should_interrupt:
self.interrupt_signal.emit(
decision.suggested_response, decision.style
)
asyncio.run_coroutine_threadsafe(_check(), self._loop)
def stop_agent(self):
self._running = False
+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())