Files
EzVibe/main.py

449 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("要运行完整 GUIpython 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()