Selaa lähdekoodia

重构(IM): 迁移客户端实现并保留旧脚本兼容

将 IM client、notifier 和 protocol 迁入可导入的 agent.im_client 包,旧 im-client 脚本通过框架导入桥继续工作;同时删除历史 client.py.bak 并清理重复实现。
SamLee 14 tuntia sitten
vanhempi
commit
84f0d28a36

+ 14 - 0
agent/agent/im_client/__init__.py

@@ -0,0 +1,14 @@
+"""Packaged IM client used by the builtin IM tools."""
+
+from .client import ChatWindow, IMClient
+from .notifier import AgentNotifier, ConsoleNotifier
+from .protocol import IMMessage, IMResponse
+
+__all__ = [
+    "AgentNotifier",
+    "ChatWindow",
+    "ConsoleNotifier",
+    "IMClient",
+    "IMMessage",
+    "IMResponse",
+]

+ 348 - 0
agent/agent/im_client/client.py

@@ -0,0 +1,348 @@
+import asyncio
+import json
+import logging
+import os
+import tempfile
+import uuid
+from datetime import datetime
+from pathlib import Path
+
+import websockets
+from filelock import FileLock
+
+from .protocol import IMMessage, IMResponse
+from .notifier import AgentNotifier, ConsoleNotifier
+
+
+class ChatWindow:
+    """单个聊天窗口的数据管理。"""
+
+    def __init__(self, chat_id: str, data_dir: Path):
+        self.chat_id = chat_id
+        self.data_dir = data_dir
+        self.data_dir.mkdir(parents=True, exist_ok=True)
+
+        self.chatbox_path = data_dir / "chatbox.jsonl"
+        self.in_pending_path = data_dir / "in_pending.json"
+        self.out_pending_path = data_dir / "out_pending.jsonl"
+
+        # 文件锁
+        self._in_pending_lock = FileLock(str(data_dir / ".in_pending.lock"))
+        self._out_pending_lock = FileLock(str(data_dir / ".out_pending.lock"))
+        self._chatbox_lock = FileLock(str(data_dir / ".chatbox.lock"))
+
+        # 初始化文件
+        if not self.chatbox_path.exists():
+            self.chatbox_path.write_text("")
+        if not self.in_pending_path.exists():
+            self.in_pending_path.write_text("[]")
+        if not self.out_pending_path.exists():
+            self.out_pending_path.write_text("")
+
+    def append_to_in_pending(self, msg: dict):
+        with self._in_pending_lock:
+            pending = self._load_json_array(self.in_pending_path)
+            pending.append(msg)
+            self._atomic_write_json(self.in_pending_path, pending)
+
+    def read_in_pending(self) -> list[dict]:
+        with self._in_pending_lock:
+            return self._load_json_array(self.in_pending_path)
+
+    def clear_in_pending(self):
+        with self._in_pending_lock:
+            self._atomic_write_json(self.in_pending_path, [])
+
+    def append_to_chatbox(self, msg: dict):
+        with self._chatbox_lock:
+            with open(self.chatbox_path, "a", encoding="utf-8") as f:
+                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+
+    def append_to_out_pending(self, msg: dict):
+        with self._out_pending_lock:
+            with open(self.out_pending_path, "a", encoding="utf-8") as f:
+                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+
+    @staticmethod
+    def _load_json_array(path: Path) -> list:
+        if not path.exists():
+            return []
+        text = path.read_text(encoding="utf-8").strip()
+        if not text:
+            return []
+        try:
+            data = json.loads(text)
+            return data if isinstance(data, list) else []
+        except json.JSONDecodeError:
+            return []
+
+    @staticmethod
+    def _atomic_write_json(path: Path, data):
+        tmp_fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
+        try:
+            with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
+                json.dump(data, f, ensure_ascii=False, indent=2)
+            os.replace(tmp_path, str(path))
+        except Exception:
+            if os.path.exists(tmp_path):
+                os.unlink(tmp_path)
+            raise
+
+
+class IMClient:
+    """IM Client - 一个实例管理多个聊天窗口。
+
+    一个 Agent (contact_id) 对应一个 IMClient 实例。
+    该实例可以管理多个 chat_id(窗口),每个窗口有独立的消息存储。
+    """
+
+    def __init__(
+        self,
+        contact_id: str,
+        server_url: str = "ws://localhost:8000",
+        data_dir: str | None = None,
+        notify_interval: float = 30.0,
+    ):
+        self.contact_id = contact_id
+        self.server_url = server_url
+        self.notify_interval = notify_interval
+
+        self.base_dir = Path(data_dir) if data_dir else Path("data") / contact_id
+        self.base_dir.mkdir(parents=True, exist_ok=True)
+
+        # 窗口管理
+        self._windows: dict[str, ChatWindow] = {}
+        self._notifiers: dict[str, AgentNotifier] = {}
+
+        self.ws = None
+        self.log = logging.getLogger(contact_id)
+        self._send_queue = asyncio.Queue()
+
+        # 消息回调:{chat_id: callback} 或 {"*": callback} 匹配所有窗口
+        self._on_message_callbacks: dict[str, callable] = {}
+
+    def open_window(
+        self, chat_id: str | None = None, notifier: AgentNotifier | None = None
+    ) -> str:
+        """打开一个新窗口。
+
+        Args:
+            chat_id: 窗口 ID(留空自动生成)
+            notifier: 该窗口的通知器
+
+        Returns:
+            窗口的 chat_id
+        """
+        if chat_id is None:
+            chat_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6]
+
+        if chat_id in self._windows:
+            return chat_id
+
+        window_dir = self.base_dir / "windows" / chat_id
+        self._windows[chat_id] = ChatWindow(chat_id, window_dir)
+        self._notifiers[chat_id] = notifier or ConsoleNotifier()
+
+        self.log.info(f"打开窗口: {chat_id}")
+        return chat_id
+
+    def close_window(self, chat_id: str):
+        """关闭一个窗口。"""
+        self._windows.pop(chat_id, None)
+        self._notifiers.pop(chat_id, None)
+
+    def on_message(self, callback: callable, chat_id: str = "*"):
+        """注册消息回调。收到消息时立即触发,无需轮询。
+
+        Args:
+            callback: 回调函数,签名 async def callback(msg: dict)
+            chat_id: 监听的窗口 ID,"*" 表示所有窗口
+        """
+        self._on_message_callbacks[chat_id] = callback
+        self.log.info("注册消息回调: %s", chat_id)
+
+    def list_windows(self) -> list[str]:
+        """列出所有打开的窗口。"""
+        return list(self._windows.keys())
+
+    async def run(self):
+        """启动 Client 服务,自动重连。"""
+        while True:
+            try:
+                # 连接时不带 chat_id,因为一个实例管理多个窗口
+                ws_url = f"{self.server_url}/ws?contact_id={self.contact_id}&chat_id=__multi__"
+                self.log.info(f"连接 {ws_url} ...")
+                async with websockets.connect(ws_url) as ws:
+                    self.ws = ws
+                    self.log.info("已连接")
+                    await asyncio.gather(
+                        self._ws_listener(),
+                        self._send_worker(),
+                        self._pending_notifier(),
+                    )
+            except (websockets.ConnectionClosed, ConnectionRefusedError, OSError) as e:
+                self.log.warning(f"连接断开: {e}, 5 秒后重连...")
+                self.ws = None
+                await asyncio.sleep(5)
+            except asyncio.CancelledError:
+                self.log.info("服务停止")
+                break
+
+    async def _ws_listener(self):
+        """监听 WebSocket,根据 receiver_chat_id 分发到对应窗口。"""
+        async for raw in self.ws:
+            try:
+                data = json.loads(raw)
+            except json.JSONDecodeError:
+                self.log.warning(f"收到无效 JSON: {raw}")
+                continue
+
+            if "sender" in data and "receiver" in data:
+                # 聊天消息
+                receiver_chat_id = data.get("receiver_chat_id")
+
+                if receiver_chat_id and receiver_chat_id in self._windows:
+                    # 定向发送到指定窗口
+                    window = self._windows[receiver_chat_id]
+                    window.append_to_in_pending(data)
+                    window.append_to_chatbox(data)
+                    self.log.info(
+                        f"收到消息 -> 窗口 {receiver_chat_id}: {data['sender']}"
+                    )
+                    await self._fire_on_message(receiver_chat_id, data)
+                elif not receiver_chat_id:
+                    # 广播到所有窗口
+                    for chat_id, window in self._windows.items():
+                        window.append_to_in_pending(data)
+                        window.append_to_chatbox(data)
+                        await self._fire_on_message(chat_id, data)
+                    self.log.info(
+                        f"收到消息 -> 广播到 {len(self._windows)} 个窗口: {data['sender']}"
+                    )
+                else:
+                    self.log.warning(f"收到消息但窗口 {receiver_chat_id} 不存在")
+
+            elif "status" in data:
+                # 发送回执
+                resp = IMResponse(**data)
+                if resp.status == "success":
+                    self.log.info(f"消息 {resp.msg_id} 发送成功")
+                else:
+                    self.log.warning(f"消息 {resp.msg_id} 发送失败: {resp.error}")
+
+    async def _fire_on_message(self, chat_id: str, data: dict):
+        """触发消息回调。"""
+        # 精确匹配
+        cb = self._on_message_callbacks.get(chat_id)
+        if cb is None:
+            # 通配符匹配
+            cb = self._on_message_callbacks.get("*")
+        if cb:
+            try:
+                await cb(data)
+            except Exception as e:
+                self.log.error(f"on_message 回调异常: {e}")
+
+    async def _send_worker(self):
+        """从队列取消息并发送。"""
+        while True:
+            msg_data = await self._send_queue.get()
+            msg = IMMessage(sender=self.contact_id, **msg_data)
+            try:
+                await self.ws.send(msg.model_dump_json())
+                self.log.info(
+                    f"发送消息: -> {msg.receiver}:{msg.receiver_chat_id or '*'}"
+                )
+                # 记录到发送方窗口的 chatbox
+                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
+                    self._windows[msg.sender_chat_id].append_to_chatbox(
+                        msg.model_dump()
+                    )
+            except Exception as e:
+                self.log.error(f"发送失败: {e}")
+                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
+                    self._windows[msg.sender_chat_id].append_to_out_pending(
+                        msg.model_dump()
+                    )
+
+    async def _pending_notifier(self):
+        """轮询各窗口的 in_pending,有新消息就调通知回调。"""
+        while True:
+            for chat_id, window in list(self._windows.items()):
+                pending = window.read_in_pending()
+                if pending:
+                    senders = list(set(m.get("sender", "unknown") for m in pending))
+                    count = len(pending)
+                    notifier = self._notifiers.get(chat_id)
+                    if notifier:
+                        try:
+                            await notifier.notify(count=count, from_contacts=senders)
+                        except Exception as e:
+                            self.log.error(f"窗口 {chat_id} 通知回调异常: {e}")
+            await asyncio.sleep(self.notify_interval)
+
+    # ── Agent 调用的工具方法 ──
+
+    def read_pending(self, chat_id: str) -> list[dict]:
+        """读取某个窗口的待处理消息,并清空。"""
+        window = self._windows.get(chat_id)
+        if window is None:
+            return []
+        pending = window.read_in_pending()
+        if pending:
+            window.clear_in_pending()
+        return pending
+
+    def send_message(
+        self,
+        chat_id: str,
+        receiver: str,
+        content: str,
+        msg_type: str = "chat",
+        receiver_chat_id: str | None = None,
+    ):
+        """从某个窗口发送消息。"""
+        msg_data = {
+            "sender_chat_id": chat_id,
+            "receiver": receiver,
+            "content": content,
+            "msg_type": msg_type,
+            "receiver_chat_id": receiver_chat_id,
+        }
+        self._send_queue.put_nowait(msg_data)
+
+    def get_chat_history(
+        self, chat_id: str, peer_id: str | None = None, limit: int = 20
+    ) -> list[dict]:
+        """查询某个窗口的聊天历史。"""
+        window = self._windows.get(chat_id)
+        if window is None or not window.chatbox_path.exists():
+            return []
+
+        lines = window.chatbox_path.read_text(encoding="utf-8").strip().splitlines()
+        messages = []
+        for line in reversed(lines):
+            if not line.strip():
+                continue
+            try:
+                m = json.loads(line)
+            except json.JSONDecodeError:
+                continue
+
+            if peer_id and m.get("sender") != peer_id and m.get("receiver") != peer_id:
+                continue
+
+            messages.append(
+                {
+                    "sender": m.get("sender", "unknown"),
+                    "receiver": m.get("receiver", "unknown"),
+                    "content": m.get("content", ""),
+                    "msg_type": m.get("msg_type", "chat"),
+                }
+            )
+
+            if len(messages) >= limit:
+                break
+
+        messages.reverse()
+        return messages

+ 22 - 0
agent/agent/im_client/notifier.py

@@ -0,0 +1,22 @@
+import logging
+
+log = logging.getLogger(__name__)
+
+
+class AgentNotifier:
+    """Agent 通知接口基类。
+
+    当 pending.json 有新消息时,Client 会调用 notify()。
+    每个 Agent 可以继承此类实现自己的通知方式。
+    """
+
+    async def notify(self, count: int, from_contacts: list[str]):
+        raise NotImplementedError
+
+
+class ConsoleNotifier(AgentNotifier):
+    """默认实现:打印到控制台。"""
+
+    async def notify(self, count: int, from_contacts: list[str]):
+        sources = ", ".join(from_contacts)
+        log.info(f"[IM 通知] 你有 {count} 条新消息,来自: {sources}")

+ 23 - 0
agent/agent/im_client/protocol.py

@@ -0,0 +1,23 @@
+from pydantic import BaseModel
+from typing import Optional
+import uuid
+
+
+class IMMessage(BaseModel):
+    msg_id: str = ""
+    sender: str
+    receiver: str
+    content: str
+    msg_type: str = "chat"  # chat | image | video | system
+    sender_chat_id: Optional[str] = None  # 发送方窗口 ID
+    receiver_chat_id: Optional[str] = None  # 接收方窗口 ID(指定则定向,否则广播)
+
+    def model_post_init(self, _context):
+        if not self.msg_id:
+            self.msg_id = uuid.uuid4().hex[:12]
+
+
+class IMResponse(BaseModel):
+    status: str  # "success" | "failed"
+    msg_id: str
+    error: Optional[str] = None

+ 1 - 10
agent/agent/tools/builtin/im/chat.py

@@ -6,20 +6,11 @@
 import asyncio
 import json
 import logging
-import os
-import sys
 from typing import Optional
 
+from agent.im_client import AgentNotifier, IMClient
 from agent.tools import tool, ToolResult, ToolContext
 
-# 将 im-client 目录加入 sys.path
-_IM_CLIENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "im-client"))
-if _IM_CLIENT_DIR not in sys.path:
-    sys.path.insert(0, _IM_CLIENT_DIR)
-
-from client import IMClient  # noqa: E402
-from notifier import AgentNotifier  # noqa: E402
-
 logger = logging.getLogger(__name__)
 
 # ── 全局状态 ──

+ 22 - 0
agent/im-client/_framework_import.py

@@ -0,0 +1,22 @@
+"""Load the packaged IM implementation from legacy standalone scripts."""
+
+from __future__ import annotations
+
+import importlib
+import sys
+from pathlib import Path
+from types import ModuleType
+
+
+def load(module_name: str) -> ModuleType:
+    try:
+        return importlib.import_module(module_name)
+    except ModuleNotFoundError as exc:
+        if exc.name != "agent":
+            raise
+        project_root = str(Path(__file__).resolve().parent.parent)
+        sys.path.insert(0, project_root)
+        try:
+            return importlib.import_module(module_name)
+        finally:
+            sys.path.remove(project_root)

+ 6 - 331
agent/im-client/client.py

@@ -1,335 +1,10 @@
-import asyncio
-import json
-import logging
-import os
-import tempfile
-import uuid
-from datetime import datetime
-from pathlib import Path
+"""Compatibility module for the packaged :mod:`agent.im_client.client`."""
 
-import websockets
-from filelock import FileLock
+from _framework_import import load
 
-from protocol import IMMessage, IMResponse
-from notifier import AgentNotifier, ConsoleNotifier
+_module = load("agent.im_client.client")
 
-logging.basicConfig(level=logging.INFO, format="%(asctime)s [CLIENT:%(name)s] %(message)s")
-
-
-class ChatWindow:
-    """单个聊天窗口的数据管理。"""
-
-    def __init__(self, chat_id: str, data_dir: Path):
-        self.chat_id = chat_id
-        self.data_dir = data_dir
-        self.data_dir.mkdir(parents=True, exist_ok=True)
-
-        self.chatbox_path = data_dir / "chatbox.jsonl"
-        self.in_pending_path = data_dir / "in_pending.json"
-        self.out_pending_path = data_dir / "out_pending.jsonl"
-
-        # 文件锁
-        self._in_pending_lock = FileLock(str(data_dir / ".in_pending.lock"))
-        self._out_pending_lock = FileLock(str(data_dir / ".out_pending.lock"))
-        self._chatbox_lock = FileLock(str(data_dir / ".chatbox.lock"))
-
-        # 初始化文件
-        if not self.chatbox_path.exists():
-            self.chatbox_path.write_text("")
-        if not self.in_pending_path.exists():
-            self.in_pending_path.write_text("[]")
-        if not self.out_pending_path.exists():
-            self.out_pending_path.write_text("")
-
-    def append_to_in_pending(self, msg: dict):
-        with self._in_pending_lock:
-            pending = self._load_json_array(self.in_pending_path)
-            pending.append(msg)
-            self._atomic_write_json(self.in_pending_path, pending)
-
-    def read_in_pending(self) -> list[dict]:
-        with self._in_pending_lock:
-            return self._load_json_array(self.in_pending_path)
-
-    def clear_in_pending(self):
-        with self._in_pending_lock:
-            self._atomic_write_json(self.in_pending_path, [])
-
-    def append_to_chatbox(self, msg: dict):
-        with self._chatbox_lock:
-            with open(self.chatbox_path, "a", encoding="utf-8") as f:
-                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
-
-    def append_to_out_pending(self, msg: dict):
-        with self._out_pending_lock:
-            with open(self.out_pending_path, "a", encoding="utf-8") as f:
-                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
-
-    @staticmethod
-    def _load_json_array(path: Path) -> list:
-        if not path.exists():
-            return []
-        text = path.read_text(encoding="utf-8").strip()
-        if not text:
-            return []
-        try:
-            data = json.loads(text)
-            return data if isinstance(data, list) else []
-        except json.JSONDecodeError:
-            return []
-
-    @staticmethod
-    def _atomic_write_json(path: Path, data):
-        tmp_fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
-        try:
-            with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
-                json.dump(data, f, ensure_ascii=False, indent=2)
-            os.replace(tmp_path, str(path))
-        except Exception:
-            if os.path.exists(tmp_path):
-                os.unlink(tmp_path)
-            raise
-
-
-class IMClient:
-    """IM Client - 一个实例管理多个聊天窗口。
-
-    一个 Agent (contact_id) 对应一个 IMClient 实例。
-    该实例可以管理多个 chat_id(窗口),每个窗口有独立的消息存储。
-    """
-
-    def __init__(
-        self,
-        contact_id: str,
-        server_url: str = "ws://localhost:8000",
-        data_dir: str | None = None,
-        notify_interval: float = 30.0,
-    ):
-        self.contact_id = contact_id
-        self.server_url = server_url
-        self.notify_interval = notify_interval
-
-        self.base_dir = Path(data_dir) if data_dir else Path("data") / contact_id
-        self.base_dir.mkdir(parents=True, exist_ok=True)
-
-        # 窗口管理
-        self._windows: dict[str, ChatWindow] = {}
-        self._notifiers: dict[str, AgentNotifier] = {}
-
-        self.ws = None
-        self.log = logging.getLogger(contact_id)
-        self._send_queue = asyncio.Queue()
-
-        # 消息回调:{chat_id: callback} 或 {"*": callback} 匹配所有窗口
-        self._on_message_callbacks: dict[str, callable] = {}
-
-    def open_window(self, chat_id: str | None = None, notifier: AgentNotifier | None = None) -> str:
-        """打开一个新窗口。
-
-        Args:
-            chat_id: 窗口 ID(留空自动生成)
-            notifier: 该窗口的通知器
-
-        Returns:
-            窗口的 chat_id
-        """
-        if chat_id is None:
-            chat_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6]
-
-        if chat_id in self._windows:
-            return chat_id
-
-        window_dir = self.base_dir / "windows" / chat_id
-        self._windows[chat_id] = ChatWindow(chat_id, window_dir)
-        self._notifiers[chat_id] = notifier or ConsoleNotifier()
-
-        self.log.info(f"打开窗口: {chat_id}")
-        return chat_id
-
-    def close_window(self, chat_id: str):
-        """关闭一个窗口。"""
-        self._windows.pop(chat_id, None)
-        self._notifiers.pop(chat_id, None)
-
-    def on_message(self, callback: callable, chat_id: str = "*"):
-        """注册消息回调。收到消息时立即触发,无需轮询。
-
-        Args:
-            callback: 回调函数,签名 async def callback(msg: dict)
-            chat_id: 监听的窗口 ID,"*" 表示所有窗口
-        """
-        self._on_message_callbacks[chat_id] = callback
-        self.log.info(f"关闭窗口: {chat_id}")
-
-    def list_windows(self) -> list[str]:
-        """列出所有打开的窗口。"""
-        return list(self._windows.keys())
-
-    async def run(self):
-        """启动 Client 服务,自动重连。"""
-        while True:
-            try:
-                # 连接时不带 chat_id,因为一个实例管理多个窗口
-                ws_url = f"{self.server_url}/ws?contact_id={self.contact_id}&chat_id=__multi__"
-                self.log.info(f"连接 {ws_url} ...")
-                async with websockets.connect(ws_url) as ws:
-                    self.ws = ws
-                    self.log.info("已连接")
-                    await asyncio.gather(
-                        self._ws_listener(),
-                        self._send_worker(),
-                        self._pending_notifier(),
-                    )
-            except (websockets.ConnectionClosed, ConnectionRefusedError, OSError) as e:
-                self.log.warning(f"连接断开: {e}, 5 秒后重连...")
-                self.ws = None
-                await asyncio.sleep(5)
-            except asyncio.CancelledError:
-                self.log.info("服务停止")
-                break
-
-    async def _ws_listener(self):
-        """监听 WebSocket,根据 receiver_chat_id 分发到对应窗口。"""
-        async for raw in self.ws:
-            try:
-                data = json.loads(raw)
-            except json.JSONDecodeError:
-                self.log.warning(f"收到无效 JSON: {raw}")
-                continue
-
-            if "sender" in data and "receiver" in data:
-                # 聊天消息
-                receiver_chat_id = data.get("receiver_chat_id")
-
-                if receiver_chat_id and receiver_chat_id in self._windows:
-                    # 定向发送到指定窗口
-                    window = self._windows[receiver_chat_id]
-                    window.append_to_in_pending(data)
-                    window.append_to_chatbox(data)
-                    self.log.info(f"收到消息 -> 窗口 {receiver_chat_id}: {data['sender']}")
-                    await self._fire_on_message(receiver_chat_id, data)
-                elif not receiver_chat_id:
-                    # 广播到所有窗口
-                    for chat_id, window in self._windows.items():
-                        window.append_to_in_pending(data)
-                        window.append_to_chatbox(data)
-                        await self._fire_on_message(chat_id, data)
-                    self.log.info(f"收到消息 -> 广播到 {len(self._windows)} 个窗口: {data['sender']}")
-                else:
-                    self.log.warning(f"收到消息但窗口 {receiver_chat_id} 不存在")
-
-            elif "status" in data:
-                # 发送回执
-                resp = IMResponse(**data)
-                if resp.status == "success":
-                    self.log.info(f"消息 {resp.msg_id} 发送成功")
-                else:
-                    self.log.warning(f"消息 {resp.msg_id} 发送失败: {resp.error}")
-
-    async def _fire_on_message(self, chat_id: str, data: dict):
-        """触发消息回调。"""
-        # 精确匹配
-        cb = self._on_message_callbacks.get(chat_id)
-        if cb is None:
-            # 通配符匹配
-            cb = self._on_message_callbacks.get("*")
-        if cb:
-            try:
-                await cb(data)
-            except Exception as e:
-                self.log.error(f"on_message 回调异常: {e}")
-
-    async def _send_worker(self):
-        """从队列取消息并发送。"""
-        while True:
-            msg_data = await self._send_queue.get()
-            msg = IMMessage(sender=self.contact_id, **msg_data)
-            try:
-                await self.ws.send(msg.model_dump_json())
-                self.log.info(f"发送消息: -> {msg.receiver}:{msg.receiver_chat_id or '*'}")
-                # 记录到发送方窗口的 chatbox
-                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
-                    self._windows[msg.sender_chat_id].append_to_chatbox(msg.model_dump())
-            except Exception as e:
-                self.log.error(f"发送失败: {e}")
-                if msg.sender_chat_id and msg.sender_chat_id in self._windows:
-                    self._windows[msg.sender_chat_id].append_to_out_pending(msg.model_dump())
-
-    async def _pending_notifier(self):
-        """轮询各窗口的 in_pending,有新消息就调通知回调。"""
-        while True:
-            for chat_id, window in list(self._windows.items()):
-                pending = window.read_in_pending()
-                if pending:
-                    senders = list(set(m.get("sender", "unknown") for m in pending))
-                    count = len(pending)
-                    notifier = self._notifiers.get(chat_id)
-                    if notifier:
-                        try:
-                            await notifier.notify(count=count, from_contacts=senders)
-                        except Exception as e:
-                            self.log.error(f"窗口 {chat_id} 通知回调异常: {e}")
-            await asyncio.sleep(self.notify_interval)
-
-    # ── Agent 调用的工具方法 ──
-
-    def read_pending(self, chat_id: str) -> list[dict]:
-        """读取某个窗口的待处理消息,并清空。"""
-        window = self._windows.get(chat_id)
-        if window is None:
-            return []
-        pending = window.read_in_pending()
-        if pending:
-            window.clear_in_pending()
-        return pending
-
-    def send_message(
-        self,
-        chat_id: str,
-        receiver: str,
-        content: str,
-        msg_type: str = "chat",
-        receiver_chat_id: str | None = None,
-    ):
-        """从某个窗口发送消息。"""
-        msg_data = {
-            "sender_chat_id": chat_id,
-            "receiver": receiver,
-            "content": content,
-            "msg_type": msg_type,
-            "receiver_chat_id": receiver_chat_id,
-        }
-        self._send_queue.put_nowait(msg_data)
-
-    def get_chat_history(self, chat_id: str, peer_id: str | None = None, limit: int = 20) -> list[dict]:
-        """查询某个窗口的聊天历史。"""
-        window = self._windows.get(chat_id)
-        if window is None or not window.chatbox_path.exists():
-            return []
-
-        lines = window.chatbox_path.read_text(encoding="utf-8").strip().splitlines()
-        messages = []
-        for line in reversed(lines):
-            if not line.strip():
-                continue
-            try:
-                m = json.loads(line)
-            except json.JSONDecodeError:
-                continue
-
-            if peer_id and m.get("sender") != peer_id and m.get("receiver") != peer_id:
-                continue
-
-            messages.append({
-                "sender": m.get("sender", "unknown"),
-                "receiver": m.get("receiver", "unknown"),
-                "content": m.get("content", ""),
-                "msg_type": m.get("msg_type", "chat"),
-            })
-
-            if len(messages) >= limit:
-                break
-
-        messages.reverse()
-        return messages
+ChatWindow = _module.ChatWindow
+IMClient = _module.IMClient
 
+__all__ = ["ChatWindow", "IMClient"]

+ 0 - 248
agent/im-client/client.py.bak

@@ -1,248 +0,0 @@
-import asyncio
-import json
-import logging
-import os
-import tempfile
-import uuid
-from datetime import datetime
-from pathlib import Path
-
-import websockets
-from filelock import FileLock
-
-from protocol import IMMessage, IMResponse
-from notifier import AgentNotifier, ConsoleNotifier
-
-logging.basicConfig(level=logging.INFO, format="%(asctime)s [CLIENT:%(name)s] %(message)s")
-
-
-class IMClient:
-    """IM Client 长驻服务。
-
-    通过 WebSocket 连接 Server,通过文件与 Agent 交互。
-
-    文件约定 (data/{contact_id}/):
-        chatbox.jsonl     — 所有消息历史(收发都记录)
-        in_pending.json   — 收到的待处理消息 (JSON 数组)
-        out_pending.jsonl — 发送失败的消息
-
-    窗口模式 (window_mode=True):
-        每次运行生成新的 chat_id,消息按 chat_id 隔离
-        文件结构变为: data/{contact_id}/{chat_id}/...
-    """
-
-    def __init__(
-        self,
-        contact_id: str,
-        server_url: str = "ws://localhost:8000",
-        data_dir: str | None = None,
-        notifier: AgentNotifier | None = None,
-        notify_interval: float = 30.0,
-        window_mode: bool = False,
-        chat_id: str | None = None,
-    ):
-        self.contact_id = contact_id
-        self.server_url = server_url
-        self.notifier = notifier or ConsoleNotifier()
-        self.notify_interval = notify_interval
-        self.window_mode = window_mode
-
-        # 窗口模式:生成或使用指定的 chat_id
-        base_dir = Path(data_dir) if data_dir else Path("data") / contact_id
-        if window_mode:
-            self.chat_id = chat_id or datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6]
-            self.data_dir = base_dir / "windows" / self.chat_id
-        else:
-            self.chat_id = None
-            self.data_dir = base_dir
-
-        self.data_dir.mkdir(parents=True, exist_ok=True)
-
-        self.chatbox_path = self.data_dir / "chatbox.jsonl"
-        self.in_pending_path = self.data_dir / "in_pending.json"
-        self.out_pending_path = self.data_dir / "out_pending.jsonl"
-
-        # 文件锁
-        self._in_pending_lock = FileLock(str(self.data_dir / ".in_pending.lock"))
-        self._out_pending_lock = FileLock(str(self.data_dir / ".out_pending.lock"))
-        self._chatbox_lock = FileLock(str(self.data_dir / ".chatbox.lock"))
-
-        self.ws = None
-        self.log = logging.getLogger(f"{contact_id}:{self.chat_id}" if self.chat_id else contact_id)
-        self._send_queue = asyncio.Queue()
-
-        # 初始化文件
-        if not self.chatbox_path.exists():
-            self.chatbox_path.write_text("")
-        if not self.in_pending_path.exists():
-            self.in_pending_path.write_text("[]")
-        if not self.out_pending_path.exists():
-            self.out_pending_path.write_text("")
-
-    async def run(self):
-        """启动 Client 服务,自动重连。"""
-        while True:
-            try:
-                # 构造 WebSocket URL,带上 chat_id 参数
-                chat_id_param = self.chat_id or "default"
-                ws_url = f"{self.server_url}/ws?contact_id={self.contact_id}&chat_id={chat_id_param}"
-                self.log.info(f"连接 {ws_url} ...")
-                async with websockets.connect(ws_url) as ws:
-                    self.ws = ws
-                    self.log.info("已连接")
-                    await asyncio.gather(
-                        self._ws_listener(),
-                        self._send_worker(),
-                        self._pending_notifier(),
-                    )
-            except (websockets.ConnectionClosed, ConnectionRefusedError, OSError) as e:
-                self.log.warning(f"连接断开: {e}, 5 秒后重连...")
-                self.ws = None
-                await asyncio.sleep(5)
-            except asyncio.CancelledError:
-                self.log.info("服务停止")
-                break
-
-    # ── 协程 1: WebSocket 收消息 ──
-
-    async def _ws_listener(self):
-        """监听 WebSocket,聊天消息写 in_pending 和 chatbox,回执打日志。"""
-        async for raw in self.ws:
-            try:
-                data = json.loads(raw)
-            except json.JSONDecodeError:
-                self.log.warning(f"收到无效 JSON: {raw}")
-                continue
-
-            if "sender" in data and "receiver" in data:
-                # 聊天消息
-                self.log.info(f"收到消息: {data['sender']} -> {data['content'][:50]}")
-                self._append_to_in_pending(data)
-                self._append_to_chatbox(data)
-            elif "status" in data:
-                # 发送回执
-                resp = IMResponse(**data)
-                if resp.status == "success":
-                    self.log.info(f"消息 {resp.msg_id} 发送成功")
-                else:
-                    self.log.warning(f"消息 {resp.msg_id} 发送失败: {resp.error}")
-
-    # ── 协程 2: 发送队列处理 ──
-
-    async def _send_worker(self):
-        """从队列取消息并发送,失败则写入 out_pending。"""
-        while True:
-            msg_data = await self._send_queue.get()
-            # 填充 sender_chat_id
-            msg = IMMessage(
-                sender=self.contact_id,
-                sender_chat_id=self.chat_id or "default",
-                **msg_data
-            )
-            try:
-                await self.ws.send(msg.model_dump_json())
-                self.log.info(f"发送消息: -> {msg.receiver}:{msg.receiver_chat_id or '*'}")
-                # 记录到 chatbox
-                self._append_to_chatbox(msg.model_dump())
-            except Exception as e:
-                self.log.error(f"发送失败: {e}")
-                # 写入 out_pending
-                self._append_to_out_pending(msg.model_dump())
-
-    # ── 协程 3: 轮询 in_pending 通知 Agent ──
-
-    async def _pending_notifier(self):
-        """轮询 in_pending.json,有新消息就调通知回调。"""
-        while True:
-            pending = self._read_in_pending()
-            if pending:
-                senders = list(set(m.get("sender", "unknown") for m in pending))
-                count = len(pending)
-                try:
-                    await self.notifier.notify(count=count, from_contacts=senders)
-                except Exception as e:
-                    self.log.error(f"通知回调异常: {e}")
-            await asyncio.sleep(self.notify_interval)
-
-    # ── 文件操作 (原子性) ──
-
-    def _append_to_in_pending(self, msg: dict):
-        """将收到的消息追加到 in_pending.json。"""
-        with self._in_pending_lock:
-            pending = self._load_json_array(self.in_pending_path)
-            pending.append(msg)
-            self._atomic_write_json(self.in_pending_path, pending)
-
-    def _read_in_pending(self) -> list[dict]:
-        """读取 in_pending.json (不清空)。"""
-        with self._in_pending_lock:
-            return self._load_json_array(self.in_pending_path)
-
-    def _append_to_chatbox(self, msg: dict):
-        """追加消息到 chatbox.jsonl。"""
-        with self._chatbox_lock:
-            with open(self.chatbox_path, "a", encoding="utf-8") as f:
-                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
-
-    def _append_to_out_pending(self, msg: dict):
-        """追加发送失败的消息到 out_pending.jsonl。"""
-        with self._out_pending_lock:
-            with open(self.out_pending_path, "a", encoding="utf-8") as f:
-                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
-
-    # ── Agent 调用的工具方法 ──
-
-    def read_pending(self) -> list[dict]:
-        """Agent 读取 in_pending 中的消息,并清空。"""
-        with self._in_pending_lock:
-            pending = self._load_json_array(self.in_pending_path)
-            if not pending:
-                return []
-            # 清空 in_pending
-            self._atomic_write_json(self.in_pending_path, [])
-            return pending
-
-    def send_message(self, receiver: str, content: str, msg_type: str = "chat", receiver_chat_id: str | None = None):
-        """Agent 调用:将消息放入发送队列。
-
-        Args:
-            receiver: 接收方 contact_id
-            content: 消息内容
-            msg_type: 消息类型
-            receiver_chat_id: 接收方窗口 ID(指定则定向发送,否则广播给该 contact_id 的所有窗口)
-        """
-        msg_data = {
-            "receiver": receiver,
-            "content": content,
-            "msg_type": msg_type,
-            "receiver_chat_id": receiver_chat_id
-        }
-        self._send_queue.put_nowait(msg_data)
-
-    # ── 工具方法 ──
-
-    @staticmethod
-    def _load_json_array(path: Path) -> list:
-        if not path.exists():
-            return []
-        text = path.read_text(encoding="utf-8").strip()
-        if not text:
-            return []
-        try:
-            data = json.loads(text)
-            return data if isinstance(data, list) else []
-        except json.JSONDecodeError:
-            return []
-
-    @staticmethod
-    def _atomic_write_json(path: Path, data):
-        """原子写入:先写临时文件再 rename。"""
-        tmp_fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
-        try:
-            with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
-                json.dump(data, f, ensure_ascii=False, indent=2)
-            os.replace(tmp_path, str(path))
-        except Exception:
-            if os.path.exists(tmp_path):
-                os.unlink(tmp_path)
-            raise

+ 6 - 18
agent/im-client/notifier.py

@@ -1,22 +1,10 @@
-import logging
+"""Compatibility module for the packaged :mod:`agent.im_client.notifier`."""
 
-log = logging.getLogger(__name__)
+from _framework_import import load
 
+_module = load("agent.im_client.notifier")
 
-class AgentNotifier:
-    """Agent 通知接口基类。
+AgentNotifier = _module.AgentNotifier
+ConsoleNotifier = _module.ConsoleNotifier
 
-    当 pending.json 有新消息时,Client 会调用 notify()。
-    每个 Agent 可以继承此类实现自己的通知方式。
-    """
-
-    async def notify(self, count: int, from_contacts: list[str]):
-        raise NotImplementedError
-
-
-class ConsoleNotifier(AgentNotifier):
-    """默认实现:打印到控制台。"""
-
-    async def notify(self, count: int, from_contacts: list[str]):
-        sources = ", ".join(from_contacts)
-        log.info(f"[IM 通知] 你有 {count} 条新消息,来自: {sources}")
+__all__ = ["AgentNotifier", "ConsoleNotifier"]

+ 6 - 19
agent/im-client/protocol.py

@@ -1,23 +1,10 @@
-from pydantic import BaseModel
-from typing import Optional
-import uuid
+"""Compatibility module for the packaged :mod:`agent.im_client.protocol`."""
 
+from _framework_import import load
 
-class IMMessage(BaseModel):
-    msg_id: str = ""
-    sender: str
-    receiver: str
-    content: str
-    msg_type: str = "chat"  # chat | image | video | system
-    sender_chat_id: Optional[str] = None  # 发送方窗口 ID
-    receiver_chat_id: Optional[str] = None  # 接收方窗口 ID(指定则定向,否则广播)
+_module = load("agent.im_client.protocol")
 
-    def model_post_init(self, __context):
-        if not self.msg_id:
-            self.msg_id = uuid.uuid4().hex[:12]
+IMMessage = _module.IMMessage
+IMResponse = _module.IMResponse
 
-
-class IMResponse(BaseModel):
-    status: str  # "success" | "failed"
-    msg_id: str
-    error: Optional[str] = None
+__all__ = ["IMMessage", "IMResponse"]