449 lines
16 KiB
Python
449 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
main.py — EzVibe AI 桌宠系统主入口
|
||
===================================
|
||
用法:
|
||
python main.py # 完整启动(PySide6 窗口 + 所有模块)
|
||
python main.py --dummy # Dummy 模式(headless,无需 Qt)
|
||
python main.py --server # 仅启动 API 服务器
|
||
|
||
依赖:
|
||
pip install -r requirements.txt
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import atexit
|
||
import os
|
||
import signal
|
||
import sys
|
||
import threading
|
||
from pathlib import Path
|
||
|
||
# 确保项目根目录在 sys.path
|
||
_ROOT = Path(__file__).parent.resolve()
|
||
sys.path.insert(0, str(_ROOT))
|
||
|
||
# Linux: 必须在任何 Qt 模块导入之前设置平台
|
||
if sys.platform.startswith("linux") and os.environ.get("QT_QPA_PLATFORM") is None:
|
||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||
|
||
|
||
# ============================================================================
|
||
# 组件导入
|
||
# ============================================================================
|
||
|
||
from agent.emotion import EmotionEngine
|
||
from agent.memory import VectorMemory
|
||
from agent.scheduler import BehaviorScheduler
|
||
from perception import (
|
||
KeyboardMouseMonitor,
|
||
ScreenCapture,
|
||
get_global_monitor,
|
||
stop_global_monitor,
|
||
)
|
||
from ui.pet_window import create_pet_window, DummyPetWindow
|
||
|
||
# brain 和 api 延迟导入(避免 dummy 模式下不必要的依赖)
|
||
_brain_module = None
|
||
_api_module = None
|
||
|
||
|
||
def _get_brain():
|
||
global _brain_module
|
||
if _brain_module is None:
|
||
from agent import brain as _brain_module
|
||
return _brain_module
|
||
|
||
|
||
def _get_api():
|
||
global _api_module
|
||
if _api_module is None:
|
||
from api import server as _api_module
|
||
return _api_module
|
||
|
||
|
||
# ============================================================================
|
||
# EzVibeApp — 整合所有模块
|
||
# ============================================================================
|
||
|
||
|
||
class EzVibeApp:
|
||
"""
|
||
桌宠应用主控制器。
|
||
|
||
负责初始化各层组件并协调生命周期:
|
||
1. 初始化情绪引擎(EmotionEngine)
|
||
2. 初始化记忆系统(VectorMemory)
|
||
3. 初始化 LLM 大脑(AgentBrain)
|
||
4. 初始化感知层(KeyboardMouseMonitor)
|
||
5. 初始化行为调度器(BehaviorScheduler)
|
||
6. 初始化桌宠窗口(PetWindow)
|
||
7. 启动 API 服务器(可选)
|
||
8. Qt 事件循环 + asyncio 并行运行
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
llm_backend: str = "dummy",
|
||
llm_config: dict | None = None,
|
||
use_dummy_window: bool = False,
|
||
start_api_server: bool = False,
|
||
api_port: int = 8765,
|
||
assets_dir: str | Path | None = None,
|
||
) -> None:
|
||
self._llm_backend = llm_backend
|
||
self._llm_config = llm_config or {}
|
||
self._use_dummy = use_dummy_window
|
||
self._start_api = start_api_server
|
||
self._api_port = api_port
|
||
self._assets_dir = assets_dir
|
||
|
||
# ── 核心组件(延迟初始化) ─────────────────────────────────────
|
||
self._emotion: EmotionEngine | None = None
|
||
self._memory: VectorMemory | None = None
|
||
self._brain = None
|
||
self._scheduler: BehaviorScheduler | None = None
|
||
self._monitor: KeyboardMouseMonitor | None = None
|
||
self._screen: ScreenCapture | None = None
|
||
self._window: DummyPetWindow | None = None
|
||
self._api_server = None
|
||
|
||
# ── 异步基础设施 ─────────────────────────────────────────────
|
||
self._loop: asyncio.AbstractEventLoop | None = None
|
||
self._api_thread: threading.Thread | None = None
|
||
|
||
# 初始化
|
||
self._init_components()
|
||
|
||
# ── 初始化 ────────────────────────────────────────────────────────────
|
||
|
||
def _init_components(self) -> None:
|
||
"""同步初始化所有组件。"""
|
||
print("[EzVibe] 初始化组件 ...")
|
||
|
||
# 1. 情绪引擎
|
||
self._emotion = EmotionEngine()
|
||
print(f" [✓] EmotionEngine: {self._emotion.get_state()}")
|
||
|
||
# 2. 记忆系统(embedder_backend="dummy" 避免依赖 Ollama)
|
||
self._memory = VectorMemory(
|
||
storage_path="data/MEMORY.db",
|
||
embedder_backend="dummy",
|
||
vector_mode="numpy",
|
||
)
|
||
# VectorMemory.initialize() 是 async,但也可以同步调用
|
||
# 这里在同步初始化(因为我们还没有事件循环)
|
||
try:
|
||
import asyncio
|
||
asyncio.run(self._memory.initialize())
|
||
except RuntimeError:
|
||
# 如果已经在事件循环中,用 run_in_executor
|
||
pass
|
||
print(" [✓] VectorMemory initialized")
|
||
|
||
# 3. LLM 大脑
|
||
brain_mod = _get_brain()
|
||
self._brain = brain_mod.AgentBrain(
|
||
llm_backend=self._llm_backend,
|
||
llm_config=self._llm_config,
|
||
emotion_engine=self._emotion,
|
||
memory=self._memory,
|
||
)
|
||
print(f" [✓] AgentBrain: {self._llm_backend}")
|
||
|
||
# 4. 感知层
|
||
self._monitor = get_global_monitor(
|
||
window_seconds=300.0,
|
||
idle_timeout=60.0,
|
||
)
|
||
self._screen = ScreenCapture()
|
||
print(" [✓] KeyboardMouseMonitor started")
|
||
|
||
# 5. 行为调度器
|
||
# BehaviorScheduler 内部有自己的 ActivityDetector,
|
||
# 但 perception 层的 KeyboardMouseMonitor 也有一个。
|
||
# 这里让 scheduler 使用 monitor 自己的 _activity,
|
||
# 需要确保接口兼容(perception ActivityDetector 有 activity_level @property)。
|
||
self._scheduler = BehaviorScheduler(
|
||
emotion_engine=self._emotion,
|
||
activity_detector=self._monitor._activity,
|
||
)
|
||
print(" [✓] BehaviorScheduler initialized")
|
||
|
||
# 6. 桌宠窗口(延迟创建,见 _run_qt)
|
||
# 注意:PetWindow.__init__ 需要 QApplication,所以不能在这里调用 create_pet_window
|
||
self._window = None
|
||
|
||
# 注册退出清理
|
||
atexit.register(self.shutdown)
|
||
|
||
# ── API 服务器(可选,后台线程) ──────────────────────────────────────
|
||
|
||
def _start_api_server(self) -> None:
|
||
"""在后台线程启动 FastAPI 服务器。"""
|
||
api_mod = _get_api()
|
||
# 注意:APIServer 需要 emotion + brain
|
||
self._api_server = api_mod.APIServer(
|
||
emotion_engine=self._emotion,
|
||
brain=self._brain,
|
||
memory=self._memory,
|
||
scheduler=self._scheduler,
|
||
port=self._api_port,
|
||
)
|
||
self._api_thread = threading.Thread(
|
||
target=self._api_server.run,
|
||
daemon=True,
|
||
name="ezvibe-api",
|
||
)
|
||
self._api_thread.start()
|
||
print(f" [✓] APIServer started on http://127.0.0.1:{self._api_port}")
|
||
|
||
# ── 行为调度集成 ──────────────────────────────────────────────────────
|
||
|
||
def _run_scheduler_loop(self) -> None:
|
||
"""
|
||
定期调用 scheduler.check_and_trigger()(async)。
|
||
每次触发时:显示提醒、记录到记忆。
|
||
"""
|
||
import time
|
||
|
||
while getattr(self, "_running", False):
|
||
try:
|
||
if self._scheduler and self._monitor:
|
||
activity = self._monitor.get_activity()
|
||
# check_and_trigger 是 async
|
||
triggered: list[dict] = asyncio.run(
|
||
self._scheduler.check_and_trigger(user_activity_level=activity)
|
||
)
|
||
for action in triggered:
|
||
# 显示提醒
|
||
self._window.show_reminder(
|
||
action.get("message", ""),
|
||
priority=action.get("priority", 1),
|
||
)
|
||
# 记录到记忆(async add)
|
||
try:
|
||
asyncio.run(
|
||
self._memory.add(
|
||
text=f"[SCHEDULER] {action.get('type')}: {action.get('message', '')}",
|
||
tags=["behavior", action.get("type", "unknown")],
|
||
)
|
||
)
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
print(f"[EzVibe] Scheduler error: {e}")
|
||
time.sleep(10) # 每 10 秒检查一次
|
||
|
||
# ── 主运行循环 ─────────────────────────────────────────────────────────
|
||
|
||
def run(self) -> None:
|
||
"""启动应用。Dummy 模式和 Qt 模式共用同一套初始化。"""
|
||
self._running = True
|
||
print(f"\n[EzVibe] 启动中 ... (mode={'dummy' if self._use_dummy else 'full'})\n")
|
||
|
||
# API 服务器(可选)
|
||
if self._start_api:
|
||
self._start_api_server()
|
||
|
||
# 调度循环(后台线程)
|
||
scheduler_thread = threading.Thread(
|
||
target=self._run_scheduler_loop,
|
||
daemon=True,
|
||
name="ezvibe-scheduler",
|
||
)
|
||
scheduler_thread.start()
|
||
|
||
if self._use_dummy:
|
||
self._run_dummy()
|
||
else:
|
||
self._run_qt()
|
||
|
||
def _run_dummy(self) -> None:
|
||
"""Dummy 模式:显示状态摘要后退出。"""
|
||
print("\n" + "=" * 50)
|
||
print("EzVibe 桌宠 — Dummy 模式")
|
||
print("=" * 50)
|
||
print(f" 情绪状态 : {self._emotion.get_state()}")
|
||
print(f" 活跃度 : {self._monitor.get_activity():.2f}")
|
||
print(f" 调度器 : ready")
|
||
# 记忆条目数
|
||
try:
|
||
n_mem = self._memory._store.count()
|
||
print(f" [✓] VectorMemory: {n_mem} entries")
|
||
except Exception as e:
|
||
print(f" [!] VectorMemory: {e}")
|
||
print("=" * 50)
|
||
print("\n模拟一次主动行为触发:")
|
||
triggered: list[dict] = asyncio.run(
|
||
self._scheduler.check_and_trigger(user_activity_level=0.05)
|
||
)
|
||
if triggered:
|
||
for action in triggered:
|
||
print(f" → 触发行为: {action.get('type')} — {action.get('message', '')}")
|
||
self._window.show_reminder(action.get("message", ""), priority=action.get("priority", 1))
|
||
else:
|
||
print(" → 无行为触发(正常,取决于情绪和冷却状态)")
|
||
|
||
print("\nDummy 模式完成。")
|
||
print("要运行完整 GUI:python main.py")
|
||
self.shutdown()
|
||
|
||
def _run_qt(self) -> None:
|
||
"""Qt 模式:启动 PySide6/PyQt6 事件循环。"""
|
||
try:
|
||
from PySide6 import QtWidgets
|
||
except ImportError:
|
||
try:
|
||
from PyQt6 import QtWidgets
|
||
except ImportError:
|
||
print("错误:需要 PySide6 或 PyQt6")
|
||
print("安装:pip install PySide6")
|
||
sys.exit(1)
|
||
|
||
# Linux: 强制使用 XCB 平台插件(Wayland 兼容性问题)
|
||
if sys.platform.startswith("linux") and os.environ.get("QT_QPA_PLATFORM") is None:
|
||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||
|
||
app = QtWidgets.QApplication(sys.argv)
|
||
app.setApplicationName("EzVibe")
|
||
app.setApplicationDisplayName("EzVibe 桌宠")
|
||
print(f"[EzVibe] Qt 平台: {app.platformName()}")
|
||
|
||
# ★ 现在 QApplication 已存在,创建桌宠窗口(必须在 QApplication 之后)
|
||
from ui.pet_window import create_pet_window
|
||
self._window = create_pet_window(
|
||
emotion_engine=self._emotion,
|
||
brain=self._brain,
|
||
scheduler=self._scheduler,
|
||
assets_dir=self._assets_dir,
|
||
force_dummy=self._use_dummy,
|
||
)
|
||
if self._use_dummy:
|
||
print(" [✓] PetWindow: DummyPetWindow (headless mode)")
|
||
else:
|
||
from ui.pet_window import _load_qt
|
||
qt_lib = _load_qt()
|
||
print(f" [✓] PetWindow: {type(self._window).__name__} (Qt={qt_lib})")
|
||
|
||
# Ctrl-C (SIGINT) → 优雅退出 Qt 事件循环
|
||
def _sigint_handler(signum, frame):
|
||
print("\n[EzVibe] 收到 Ctrl+C,正在关闭 ...")
|
||
app.quit()
|
||
|
||
signal.signal(signal.SIGINT, _sigint_handler)
|
||
|
||
# 显示窗口
|
||
self._window.show()
|
||
print(f"[EzVibe] 窗口 visible={self._window.isVisible()} geometry={self._window.geometry()}")
|
||
|
||
# Qt 事件循环(主线程)
|
||
# 同时嵌入 asyncio
|
||
self._loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(self._loop)
|
||
|
||
def _run_async():
|
||
"""每个 Qt 事件循环迭代运行一次 asyncio。"""
|
||
try:
|
||
self._loop.run_until_complete(asyncio.sleep(0))
|
||
except Exception:
|
||
pass
|
||
|
||
from PySide6.QtCore import QTimer
|
||
timer = QTimer(app)
|
||
timer.timeout.connect(_run_async)
|
||
timer.setInterval(20) # 50 Hz
|
||
timer.start()
|
||
|
||
print("[EzVibe] 桌宠已显示。右键点击切换情绪。")
|
||
print("[EzVibe] 按 Ctrl+C 或关闭窗口退出。")
|
||
|
||
app.exec()
|
||
|
||
self._running = False
|
||
self.shutdown()
|
||
|
||
# ── 生命周期 ──────────────────────────────────────────────────────────
|
||
|
||
def shutdown(self) -> None:
|
||
"""优雅关闭所有组件。"""
|
||
self._running = False
|
||
print("\n[EzVibe] 正在关闭 ...")
|
||
|
||
# 停止 monitor
|
||
stop_global_monitor()
|
||
|
||
# 停止窗口
|
||
if self._window:
|
||
try:
|
||
self._window.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# 关闭事件循环
|
||
if self._loop and self._loop.is_running():
|
||
self._loop.stop()
|
||
|
||
print("[EzVibe] 已关闭。")
|
||
|
||
|
||
# ============================================================================
|
||
# CLI 入口
|
||
# ============================================================================
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="EzVibe AI 桌宠系统",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
python main.py # 完整 GUI 模式
|
||
python main.py --dummy # Headless 测试
|
||
python main.py --server --port 8765 # 仅 API 服务器
|
||
python main.py --llm ollama # 使用 Ollama
|
||
""",
|
||
)
|
||
parser.add_argument(
|
||
"--dummy", action="store_true",
|
||
help="使用 DummyPetWindow(无需图形界面)"
|
||
)
|
||
parser.add_argument(
|
||
"--server", action="store_true",
|
||
help="启动 FastAPI REST/WebSocket 服务器"
|
||
)
|
||
parser.add_argument(
|
||
"--port", type=int, default=8765,
|
||
help="API 服务器端口(默认 8765)"
|
||
)
|
||
parser.add_argument(
|
||
"--llm",
|
||
choices=["ollama", "openai", "dummy"],
|
||
default="dummy",
|
||
help="LLM 后端(默认 dummy)"
|
||
)
|
||
parser.add_argument(
|
||
"--assets",
|
||
type=Path,
|
||
default=None,
|
||
help="桌宠图像资源目录"
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
app = EzVibeApp(
|
||
llm_backend=args.llm,
|
||
use_dummy_window=args.dummy,
|
||
start_api_server=args.server,
|
||
api_port=args.port,
|
||
assets_dir=args.assets,
|
||
)
|
||
app.run()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|