Initial commit: Air Agent Framework v2:全双工对话智能体(打断、插话、主动开口)
This commit is contained in:
@@ -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
@@ -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