From a6820b2cbb4a6945805e3aca8ac4ed840849cfe7 Mon Sep 17 00:00:00 2001 From: DeepSeek-Web Adapter Date: Fri, 17 Apr 2026 16:11:31 +0800 Subject: [PATCH] feat: DeepSeek Web Adapter - Array platform integration with browser control panel --- array_sdk/__init__.py | 6 + array_sdk/adapter.py | 186 ++++++++++ array_sdk/client.py | 641 +++++++++++++++++++++++++++++++++ array_sdk/models.py | 131 +++++++ main.py | 69 ++++ src/__init__.py | 3 + src/adapter.py | 351 ++++++++++++++++++ src/admin_server.py | 194 ++++++++++ src/config.py | 126 +++++++ src/deepseek_agent_adapter.py | 656 ++++++++++++++++++++++++++++++++++ start.bat | 4 + templates/admin.html | 160 +++++++++ 12 files changed, 2527 insertions(+) create mode 100644 array_sdk/__init__.py create mode 100644 array_sdk/adapter.py create mode 100644 array_sdk/client.py create mode 100644 array_sdk/models.py create mode 100644 main.py create mode 100644 src/__init__.py create mode 100644 src/adapter.py create mode 100644 src/admin_server.py create mode 100644 src/config.py create mode 100644 src/deepseek_agent_adapter.py create mode 100644 start.bat create mode 100644 templates/admin.html diff --git a/array_sdk/__init__.py b/array_sdk/__init__.py new file mode 100644 index 0000000..db9fefd --- /dev/null +++ b/array_sdk/__init__.py @@ -0,0 +1,6 @@ +"""列阵协作系统 - Worker Python SDK""" +from .client import ArrayClient +from .adapter import BaseAdapter +from .models import Worker, Task, TaskParticipant, Message, Notification, Event, Organization, OrgMember, OrgInvite + +__all__ = ["ArrayClient", "BaseAdapter", "Worker", "Task", "TaskParticipant", "Message", "Notification", "Event", "Organization", "OrgMember", "OrgInvite"] diff --git a/array_sdk/adapter.py b/array_sdk/adapter.py new file mode 100644 index 0000000..3aa11e5 --- /dev/null +++ b/array_sdk/adapter.py @@ -0,0 +1,186 @@ +"""BaseAdapter - 适配器基类,封装 Worker 的完整生命周期""" +import asyncio + +from .client import ArrayClient +from .models import Task, Event + + +class BaseAdapter: + """适配器基类,封装 Worker 的完整生命周期:连接 -> 监听 -> 处理 + + 关于 Worker 描述(description)的推荐格式: + 在注册 Worker 或更新简历时,description 字段建议按以下结构编写, + 方便其他 Worker 和人类快速理解你的能力。 + + 推荐格式: + [一句话功能概述] + + 能力: + - 能力1 + - 能力2 + + 使用方式: + [说明怎么跟这个 Worker 交互] + + 限制:(可选) + - 限制1 + + 示例: + "多语言实时翻译工具,支持中英日韩互译。\n\n" + "能力:\n" + "- 文本翻译(中/英/日/韩)\n" + "- 自动检测源语言\n\n" + "使用方式:\n" + "@提及并发送需要翻译的文本,默认翻译为中文。\n\n" + "限制:\n" + "- 单次最多翻译 5000 字" + + 实际使用时,可通过 client.update_profile(description="...") 或注册时传入。 + + 关于组织(Organization)的说明: + Worker 可以属于多个组织,组织提供了资源隔离和协作边界。如果你的 Worker + 属于多个组织,建议在适配器初始化时通过参数指定当前工作组织上下文: + + class MyAdapter(BaseAdapter): + def __init__(self, server_url: str, api_key: str, org_id: str | None = None): + super().__init__(server_url, api_key) + self.org_id = org_id # 当前工作组织 + + 创建任务时,可以通过 org_id 参数指定任务所属组织: + + await self.client.create_task( + title="任务标题", + org_id=self.org_id # 指定组织 + ) + + 通过邀请码加入组织: + + await self.client.join_organization(invite_code="xxx") + """ + + def __init__(self, server_url: str, api_key: str): + self.server_url = server_url + self.api_key = api_key + self.client: ArrayClient | None = None + + async def on_task_invited(self, task: Task): + """被邀请加入新任务时的处理逻辑。子类必须实现。""" + raise NotImplementedError + + async def on_task_assigned(self, task: Task): + """兼容别名,推荐使用 on_task_invited""" + await self.on_task_invited(task) + + async def on_task_message(self, task_id: str, message: dict): + """收到任务消息时的处理逻辑。子类可选覆盖。""" + pass + + async def on_task_updated(self, task_id: str, changes: dict): + """任务状态/内容变更时的处理逻辑。子类可选覆盖。""" + pass + + async def on_pending_recovery(self, tasks: list[dict]): + """WebSocket 连上后收到待恢复任务列表时调用。子类可覆盖。 + + tasks 是 [{"id": "...", "title": "...", "status": "..."}] 形式的列表。 + 适配器可在此恢复之前中断的任务处理。 + """ + pass + + async def on_connected(self): + """连接成功后的回调。子类可选覆盖。""" + pass + + async def on_disconnected(self): + """WebSocket 断开时的回调,子类可覆盖""" + pass + + async def run(self): + """启动 Worker 完整生命周期。 + + 外层重连:当 HTTP 连接或整个 WebSocket 会话断开时(如服务端宕机), + 在这里做指数退避重连(5s 起步,最大 120s),每次重连会重新认证、 + 处理离线通知、再启动 WebSocket 监听。 + + 内层重连由 client.listen() 负责:当 WebSocket 连接断开但 HTTP + 会话仍然有效时,listen() 内部自动重连(2s 起步,最大 60s)。 + + 两层的区别: + - 内层(listen):仅 WebSocket 断线重连,不涉及重新认证 + - 外层(run):完整的会话重建,包括重新认证和离线通知恢复 + """ + import logging + logger = logging.getLogger(__name__) + + self._running = True + delay = 5.0 + while self._running: + try: + async with ArrayClient(self.server_url, self.api_key) as client: + self.client = client + delay = 5.0 # 重置退避 + print(f"Worker {client.worker_id} 已连接到平台") + + # 处理离线期间的通知 + await self._process_offline_notifications() + + # 启动 WebSocket 监听(在后台运行) + print("开始监听实时事件...") + listen_task = asyncio.create_task(self._listen_loop(client)) + + # 等待 WebSocket 连接建立 + await client.wait_for_ws_connection() + await self.on_connected() + + # 等待监听任务完成(通常不会完成,除非连接断开) + await listen_task + # 正常断开(非异常) + await self.on_disconnected() + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"连接失败: {e},{delay:.0f}s 后重试") + await self.on_disconnected() + await asyncio.sleep(delay) + delay = min(delay * 2, 120.0) + + async def _listen_loop(self, client: ArrayClient): + """WebSocket 监听循环""" + async for event in client.listen(): + await self._handle_event(event) + + async def _process_offline_notifications(self): + """处理离线期间积累的通知""" + notifications, total = await self.client.get_notifications(is_read=False) + if notifications: + print(f"处理 {len(notifications)} 条离线通知...") + for n in notifications: + event = Event(event_type=n.event_type, payload=n.payload or {}, timestamp=str(n.created_at)) + await self._handle_event(event) + await self.client.mark_notification_read(n.id) + + async def _handle_event(self, event: Event): + """分发事件到对应的处理方法""" + if event.event_type == "task.assigned": + task_id = event.payload.get("task_id") + if task_id: + task = await self.client.get_task(task_id) + await self.on_task_invited(task) + elif event.event_type == "participant.joined": + task_id = event.payload.get("task_id") + if task_id: + task = await self.client.get_task(task_id) + await self.on_task_invited(task) + elif event.event_type == "tasks.pending_recovery": + tasks = event.payload.get("tasks", []) + await self.on_pending_recovery(tasks) + elif event.event_type == "task.message": + task_id = event.payload.get("task_id") + if task_id: + await self.on_task_message(task_id, event.payload) + elif event.event_type == "task.updated": + task_id = event.payload.get("task_id") + changes = event.payload.get("changes", {}) + if task_id: + await self.on_task_updated(task_id, changes) + diff --git a/array_sdk/client.py b/array_sdk/client.py new file mode 100644 index 0000000..1b4ad9d --- /dev/null +++ b/array_sdk/client.py @@ -0,0 +1,641 @@ +"""ArrayClient - 列阵平台客户端,封装所有 REST API 和 WebSocket 通信""" +import asyncio +import base64 +import json +from typing import AsyncGenerator + +import httpx +import websockets + +from .models import Worker, Task, TaskParticipant, Message, Notification, Event, Organization, OrgMember, OrgInvite + + +class ArrayClient: + """列阵平台客户端,封装所有 REST API 和 WebSocket 通信""" + + PROTOCOL_VERSION = 5 # 协议版本,与 server/app/protocol.py 对应 + + def __init__(self, server_url: str, api_key: str): + self.server_url = server_url.rstrip("/") + self.api_key = api_key + self.worker_id: str | None = None + self._access_token: str | None = None + self._refresh_token: str | None = None + self._http: httpx.AsyncClient | None = None + self._ws_connected_event = asyncio.Event() + + async def __aenter__(self): + self._http = httpx.AsyncClient(base_url=self.server_url, timeout=30.0, follow_redirects=True) + await self._authenticate() + return self + + async def __aexit__(self, *args): + if self._http: + await self._http.aclose() + + async def _authenticate(self): + """用 api_key 获取 JWT token""" + resp = await self._http.post("/api/v1/workers/token", json={"api_key": self.api_key}) + resp.raise_for_status() + data = resp.json() + self._access_token = data["access_token"] + self._refresh_token = data["refresh_token"] + # 从 token 中解析 worker_id(解码JWT payload的sub字段) + payload_part = self._access_token.split(".")[1] + # 补齐 base64 padding + padding = 4 - len(payload_part) % 4 + if padding != 4: + payload_part += "=" * padding + payload = json.loads(base64.urlsafe_b64decode(payload_part)) + self.worker_id = payload["sub"] + + async def _refresh_access_token(self): + """用 refresh_token 刷新 JWT""" + resp = await self._http.post("/api/v1/workers/token/refresh", json={"refresh_token": self._refresh_token}) + resp.raise_for_status() + data = resp.json() + self._access_token = data["access_token"] + self._refresh_token = data["refresh_token"] + + def _headers(self) -> dict: + return { + "Authorization": f"Bearer {self._access_token}", + "X-Protocol-Version": str(self.PROTOCOL_VERSION) + } + + async def _request(self, method: str, path: str, **kwargs) -> httpx.Response: + """统一请求方法,自动处理 token 刷新""" + resp = await self._http.request(method, path, headers=self._headers(), **kwargs) + if resp.status_code == 401: + # token 可能过期,尝试刷新 + await self._refresh_access_token() + resp = await self._http.request(method, path, headers=self._headers(), **kwargs) + resp.raise_for_status() + return resp + + # === Worker 相关 === + + async def get_worker(self, worker_id: str) -> Worker: + """获取 Worker 详情""" + resp = await self._request("GET", f"/api/v1/workers/{worker_id}") + return Worker(**resp.json()) + + async def list_workers(self, type: str | None = None, status: str | None = None, org_id: str | None = None, page: int = 1, limit: int = 20) -> tuple[list[Worker], int]: + """获取 Worker 列表 + + 参数: + type: 按类型筛选 + status: 按状态筛选 + org_id: 按所属组织筛选 + page: 页码,默认 1 + limit: 每页数量,默认 20 + """ + params = {"page": page, "limit": limit} + if type: + params["type"] = type + if status: + params["status"] = status + if org_id: + params["org_id"] = org_id + resp = await self._request("GET", "/api/v1/workers", params=params) + data = resp.json() + return [Worker(**w) for w in data["workers"]], data["total"] + + async def update_worker(self, **kwargs) -> Worker: + """更新自己的信息""" + resp = await self._request("PATCH", f"/api/v1/workers/{self.worker_id}", json=kwargs) + return Worker(**resp.json()) + + async def update_profile(self, **kwargs) -> Worker: + """更新自己的简历信息。支持的字段:name, description, organization, location, timezone, tags, capabilities, max_concurrent_tasks, avatar_url""" + if self.worker_id is None: + raise ValueError("worker_id is not available, please authenticate first") + resp = await self._request("PATCH", f"/api/v1/workers/{self.worker_id}", json=kwargs) + return Worker(**resp.json()) + + async def get_worker_profile(self, worker_id: str) -> Worker: + """获取任意 worker 的完整简历""" + resp = await self._request("GET", f"/api/v1/workers/{worker_id}") + return Worker(**resp.json()) + + async def get_worker_workload(self, worker_id: str) -> dict: + """查询 worker 的工作负载""" + resp = await self._request("GET", f"/api/v1/workers/{worker_id}/workload") + return resp.json() + + async def discover_workers(self, skill=None, domain=None, tag=None, keyword=None, available_only=False, status=None, type=None, page=1, limit=20) -> dict: + """多条件能力发现 + + 参数: + skill: 技能名称 + domain: 领域名称 + tag: 标签 + keyword: 关键词搜索 + available_only: 是否只返回可接受的 worker + status: 按状态筛选 + type: 按类型筛选 + page: 页码,默认 1 + limit: 每页数量,默认 20 + """ + params = {"page": page, "limit": limit} + if skill: + params["skill"] = skill + if domain: + params["domain"] = domain + if tag: + params["tag"] = tag + if keyword: + params["keyword"] = keyword + if available_only: + params["available_only"] = "true" + if status: + params["status"] = status + if type: + params["type"] = type + resp = await self._request("GET", "/api/v1/workers/discover", params=params) + return resp.json() + + # === Task 相关 === + + async def create_task(self, title: str, description: str | None = None, + priority: str = "normal", parent_id: str | None = None, + tags: list[str] | None = None, deadline: str | None = None, + metadata: dict | None = None, + participant_ids: list[str] | None = None, + org_id: str | None = None) -> Task: + """创建任务 + + 参数: + title: 任务标题 + description: 任务描述 + priority: 优先级,默认 normal + parent_id: 父任务 ID + tags: 标签列表 + deadline: 截止日期 + metadata: 元数据字典 + participant_ids: 参与者 ID 列表 + org_id: 所属组织 ID + + 异常: + 可能返回 409 状态码,表示创建者或某个参与者已达到最大并发任务数限制, + detail 字段会包含具体是谁超了(名字和 ID) + """ + payload = {"title": title, "priority": priority} + if description: + payload["description"] = description + if parent_id: + payload["parent_id"] = parent_id + if tags: + payload["tags"] = tags + if deadline: + payload["deadline"] = deadline + if metadata: + payload["metadata"] = metadata + if participant_ids: + payload["participant_ids"] = participant_ids + if org_id: + payload["org_id"] = org_id + resp = await self._request("POST", "/api/v1/tasks", json=payload) + return Task(**resp.json()) + + async def get_task(self, task_id: str) -> Task: + """获取任务详情""" + resp = await self._request("GET", f"/api/v1/tasks/{task_id}") + return Task(**resp.json()) + + async def list_tasks(self, status: str | None = None, creator_id: str | None = None, + tag: str | None = None, org_id: str | None = None, + sort: str = "-created_at", page: int = 1, limit: int = 20) -> tuple[list[Task], int]: + """获取任务列表 + + 参数: + status: 按状态筛选 + creator_id: 按创建者筛选 + tag: 按标签筛选 + org_id: 按所属组织筛选 + sort: 排序方式,默认 -created_at + page: 页码,默认 1 + limit: 每页数量,默认 20 + """ + params = {"sort": sort, "page": page, "limit": limit} + if status: + params["status"] = status + if creator_id: + params["creator_id"] = creator_id + if tag: + params["tag"] = tag + if org_id: + params["org_id"] = org_id + resp = await self._request("GET", "/api/v1/tasks", params=params) + data = resp.json() + return [Task(**t) for t in data["tasks"]], data["total"] + + async def update_task(self, task_id: str, **kwargs) -> Task: + """更新任务(权限由服务端校验)""" + resp = await self._request("PATCH", f"/api/v1/tasks/{task_id}", json=kwargs) + return Task(**resp.json()) + + async def get_children(self, task_id: str) -> list[Task]: + """获取子任务列表""" + resp = await self._request("GET", f"/api/v1/tasks/{task_id}/children") + return [Task(**t) for t in resp.json()] + + # === 参与者管理 === + + async def add_participants(self, task_id: str, worker_ids: list[str]) -> list[TaskParticipant]: + """添加参与者到任务 + + 异常: + 可能返回 409 状态码,表示某个参与者已达到最大并发任务数限制, + detail 字段会包含具体是谁超了(名字和 ID) + """ + payload = {"worker_ids": worker_ids} + resp = await self._request("POST", f"/api/v1/tasks/{task_id}/participants", json=payload) + return [TaskParticipant(**p) for p in resp.json()] + + async def list_participants(self, task_id: str) -> list[TaskParticipant]: + """获取任务的参与者列表""" + resp = await self._request("GET", f"/api/v1/tasks/{task_id}/participants") + return [TaskParticipant(**p) for p in resp.json()] + + async def remove_participant(self, task_id: str, worker_id: str) -> None: + """从任务中移除参与者""" + await self._request("DELETE", f"/api/v1/tasks/{task_id}/participants/{worker_id}") + + # === Message 相关 === + + async def send_message(self, task_id: str, content: str, type: str = "comment", + reply_to_id: str | None = None, + mention_type: str = "none", + mention_ids: list[str] | None = None, + scope: str = "chat", + private_to_id: str | None = None) -> Message: + """在任务上发消息 + + scope 参数: + - "chat"(默认):正常对话消息 + - "trace":适配器响应/状态记录,不参与正常聊天流 + """ + payload = {"content": content, "type": type, "scope": scope} + if reply_to_id: + payload["reply_to_id"] = reply_to_id + if mention_type: + payload["mention_type"] = mention_type + if mention_ids: + payload["mention_ids"] = mention_ids + if private_to_id: + payload["private_to_id"] = private_to_id + resp = await self._request("POST", f"/api/v1/tasks/{task_id}/messages", json=payload) + return Message(**resp.json()) + + async def list_messages(self, task_id: str, page: int = 1, limit: int = 20, + sort: str = "-created_at", + scope: str = "chat", + include_private: bool = False, + include_system: bool = False, + type_filter: str | None = None) -> tuple[list[Message], int]: + """获取任务消息列表 + + scope 参数: + - "chat"(默认):只返回对话消息 + - "trace":只返回适配器响应/状态记录 + - "all":返回全部消息 + """ + params = {"page": page, "limit": limit, "sort": sort, "scope": scope} + if include_private: + params["include_private"] = "true" + if include_system: + params["include_system"] = "true" + if type_filter: + params["type_filter"] = type_filter + resp = await self._request("GET", f"/api/v1/tasks/{task_id}/messages", params=params) + data = resp.json() + return [Message(**m) for m in data["messages"]], data["total"] + + # === Notification 相关 === + + async def get_notifications(self, is_read: bool | None = None, page: int = 1, + limit: int = 50) -> tuple[list[Notification], int]: + """获取通知列表""" + params = {"page": page, "limit": limit} + if is_read is not None: + params["is_read"] = str(is_read).lower() + resp = await self._request("GET", "/api/v1/notifications", params=params) + data = resp.json() + return [Notification(**n) for n in data["notifications"]], data["total"] + + async def mark_notification_read(self, notification_id: str) -> None: + """标记单条通知已读""" + await self._request("PATCH", f"/api/v1/notifications/{notification_id}/read") + + async def mark_all_notifications_read(self) -> None: + """标记所有通知已读""" + await self._request("POST", "/api/v1/notifications/read-all") + + # === Review 相关 === + + async def create_review(self, worker_id: str, task_id: str, rating: int, + capability_tags: list[str] | None = None, + comment: str | None = None, + suggestion: str | None = None) -> dict: + """为某个 worker 在某个任务中创建评价(调用者必须是任务创建者)""" + data = {"task_id": task_id, "rating": rating} + if capability_tags is not None: + data["capability_tags"] = capability_tags + if comment is not None: + data["comment"] = comment + if suggestion is not None: + data["suggestion"] = suggestion + resp = await self._request("POST", f"/api/v1/workers/{worker_id}/reviews", json=data) + return resp.json() + + async def get_worker_reviews(self, worker_id: str, page: int = 1, limit: int = 20) -> dict: + """获取某个 worker 收到的评价列表""" + resp = await self._request("GET", f"/api/v1/workers/{worker_id}/reviews", + params={"page": page, "limit": limit}) + return resp.json() + + async def get_review_summary(self, worker_id: str) -> dict: + """获取某个 worker 的评价汇总""" + resp = await self._request("GET", f"/api/v1/workers/{worker_id}/reviews/summary") + return resp.json() + + # === 组织管理 === + + async def create_organization(self, name: str, description: str | None = None) -> Organization: + """创建新组织 + + 参数: + name: 组织名称 + description: 组织描述 + + 返回: + 创建的组织信息 + """ + payload = {"name": name} + if description: + payload["description"] = description + resp = await self._request("POST", "/api/v1/organizations", json=payload) + return Organization(**resp.json()) + + async def list_my_organizations(self) -> list[Organization]: + """获取我所属的所有组织 + + 返回: + 组织列表 + """ + resp = await self._request("GET", "/api/v1/organizations") + return [Organization(**o) for o in resp.json()] + + async def get_organization(self, org_id: str) -> Organization: + """获取组织详情 + + 参数: + org_id: 组织 ID + + 返回: + 组织详细信息 + """ + resp = await self._request("GET", f"/api/v1/organizations/{org_id}") + return Organization(**resp.json()) + + async def update_organization(self, org_id: str, name: str | None = None, description: str | None = None) -> Organization: + """更新组织信息 + + 参数: + org_id: 组织 ID + name: 新名称(可选) + description: 新描述(可选) + + 返回: + 更新后的组织信息 + + 注意: + 需要组织管理员权限 + """ + payload = {} + if name is not None: + payload["name"] = name + if description is not None: + payload["description"] = description + resp = await self._request("PATCH", f"/api/v1/organizations/{org_id}", json=payload) + return Organization(**resp.json()) + + # === 邀请码管理 === + + async def create_invite(self, org_id: str, expires_at: str | None = None, max_uses: int | None = None) -> OrgInvite: + """创建组织邀请码 + + 参数: + org_id: 组织 ID + expires_at: 过期时间(ISO 格式字符串,可选) + max_uses: 最大使用次数(可选) + + 返回: + 创建的邀请码信息 + + 注意: + 需要组织管理员权限 + """ + payload = {} + if expires_at: + payload["expires_at"] = expires_at + if max_uses is not None: + payload["max_uses"] = max_uses + resp = await self._request("POST", f"/api/v1/organizations/{org_id}/invites", json=payload) + return OrgInvite(**resp.json()) + + async def list_invites(self, org_id: str) -> list[OrgInvite]: + """获取组织的邀请码列表 + + 参数: + org_id: 组织 ID + + 返回: + 邀请码列表 + + 注意: + 需要组织管理员权限 + """ + resp = await self._request("GET", f"/api/v1/organizations/{org_id}/invites") + return [OrgInvite(**i) for i in resp.json()] + + async def join_organization(self, invite_code: str) -> dict: + """通过邀请码加入组织 + + 参数: + invite_code: 邀请码 + + 返回: + 加入结果,包含 org_id、worker_id、role、joined_at + """ + payload = {"invite_code": invite_code} + resp = await self._request("POST", "/api/v1/organizations/join", json=payload) + return resp.json() + + # === 组织成员管理 === + + async def list_org_members(self, org_id: str) -> list[OrgMember]: + """获取组织成员列表 + + 参数: + org_id: 组织 ID + + 返回: + 成员列表 + """ + resp = await self._request("GET", f"/api/v1/organizations/{org_id}/members") + return [OrgMember(**m) for m in resp.json()] + + async def remove_org_member(self, org_id: str, worker_id: str) -> None: + """移除组织成员 + + 参数: + org_id: 组织 ID + worker_id: 要移除的成员 Worker ID + + 注意: + 需要组织管理员权限,不能移除自己 + """ + await self._request("DELETE", f"/api/v1/organizations/{org_id}/members/{worker_id}") + + async def update_member_role(self, org_id: str, worker_id: str, role: str) -> OrgMember: + """修改成员角色 + + 参数: + org_id: 组织 ID + worker_id: 成员 Worker ID + role: 新角色("admin" 或 "member") + + 返回: + 更新后的成员信息 + + 注意: + 需要组织管理员权限 + """ + payload = {"role": role} + resp = await self._request("PATCH", f"/api/v1/organizations/{org_id}/members/{worker_id}/role", json=payload) + # 服务端返回的是简化格式,构造 OrgMember + data = resp.json() + return OrgMember( + worker_id=data["worker_id"], + role=data["role"], + worker_name="", + worker_type="", + joined_at=None + ) + + # === Admin 管理 === + + async def admin_rename_worker(self, worker_id: str, name: str, **kwargs): + """Admin 修改 Worker 信息 + + 仅 type="admin" 的 Worker 可调用。 + + Args: + worker_id: 目标 Worker ID + name: 新名称 + **kwargs: 其他可选字段 (description, capabilities, organization, location, timezone, tags, avatar_url) + + Returns: + 修改后的 Worker 信息 + + Raises: + 403: 调用者不是 admin + 404: Worker 不存在 + """ + data = {"name": name, **kwargs} + return await self._request("PATCH", f"/admin/workers/{worker_id}", json=data) + + async def admin_delete_worker(self, worker_id: str): + """Admin 软删除 Worker + + 将 Worker 状态设为 deleted,保留历史数据。被删除的 Worker 无法登录和出现在列表中。 + 仅 type="admin" 的 Worker 可调用。 + + Args: + worker_id: 目标 Worker ID + + Returns: + 删除确认信息 + + Raises: + 403: 调用者不是 admin + 404: Worker 不存在 + """ + return await self._request("DELETE", f"/admin/workers/{worker_id}") + + async def admin_create_worker(self, name: str, type: str, **kwargs): + """Admin 代注册 Worker + + Admin 代替他人注册 Worker,返回包含 API Key 的注册信息。 + 仅 type="admin" 的 Worker 可调用。 + + Args: + name: Worker 名称 + type: Worker 类型 (human/ai/tool/admin) + **kwargs: 其他可选字段 (description, capabilities, organization, location, timezone, tags, avatar_url) + + Returns: + 注册信息,包含 id 和 api_key + + Raises: + 403: 调用者不是 admin + """ + data = {"name": name, "type": type, **kwargs} + return await self._request("POST", f"/admin/workers", json=data) + + # === WebSocket 实时监听 === + + async def _ensure_authenticated(self): + """确保已认证,如果未认证则进行认证""" + if self._access_token is None: + await self._authenticate() + + def _build_ws_url(self) -> str: + """构建 WebSocket URL""" + ws_url = self.server_url.replace("http://", "ws://").replace("https://", "wss://") + return f"{ws_url}/api/v1/ws?token={self._access_token}&protocol={self.PROTOCOL_VERSION}" + + async def listen(self) -> AsyncGenerator[Event, None]: + """连接 WebSocket 并 yield 实时事件。自动心跳保活,带断线重连。""" + import logging + logger = logging.getLogger(__name__) + + delay = 2.0 + while True: + try: + await self._ensure_authenticated() + ws_url = self._build_ws_url() + async with websockets.connect(ws_url) as ws: + delay = 2.0 # 重置退避 + # 启动心跳任务 + async def heartbeat(): + while True: + try: + await asyncio.sleep(30) + await ws.send("ping") + except Exception: + break + + heartbeat_task = asyncio.create_task(heartbeat()) + # 通知 WebSocket 已连接 + self._ws_connected_event.set() + try: + async for raw_message in ws: + if raw_message == "pong": + continue + data = json.loads(raw_message) + yield Event(**data) + finally: + heartbeat_task.cancel() + except asyncio.CancelledError: + return + except Exception as e: + logger.warning(f"WebSocket 断开: {e},{delay:.0f}s 后重连") + await asyncio.sleep(delay) + delay = min(delay * 2, 60.0) + + async def wait_for_ws_connection(self, timeout: float = 10.0): + """等待 WebSocket 连接建立""" + await asyncio.wait_for(self._ws_connected_event.wait(), timeout=timeout) diff --git a/array_sdk/models.py b/array_sdk/models.py new file mode 100644 index 0000000..8be896b --- /dev/null +++ b/array_sdk/models.py @@ -0,0 +1,131 @@ +"""SDK 数据模型 - 与服务端响应对应的 Pydantic 模型""" +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class Worker(BaseModel): + """Worker 模型""" + id: str + name: str + type: str + description: str | None = None + capabilities: dict | None = None + status: str + created_at: datetime + last_seen_at: datetime | None = None + organization: str | None = None + location: str | None = None + timezone: str | None = None + avatar_url: str | None = None + tags: list | None = None + max_concurrent_tasks: int | None = None + + model_config = ConfigDict(from_attributes=True) + + +class TaskParticipant(BaseModel): + """任务参与者模型""" + id: str + task_id: str + worker_id: str + is_creator: bool + joined_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class Task(BaseModel): + """任务模型""" + id: str + title: str + description: str | None = None + status: str + priority: str + creator_id: str + parent_id: str | None = None + tags: list[str] | None = None + deadline: datetime | None = None + result: str | None = None + metadata: dict | None = None + created_at: datetime + updated_at: datetime + last_message_at: datetime | None = None + participants: list[TaskParticipant] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class Message(BaseModel): + """消息模型""" + id: str + task_id: str + sender_id: str + content: str + type: str + reply_to_id: str | None = None + created_at: datetime + mention_type: str = "none" + mention_ids: list[str] | None = None + scope: str = "chat" + private_to_id: str | None = None + + model_config = ConfigDict(from_attributes=True) + + +class Notification(BaseModel): + """通知模型""" + id: str + worker_id: str + event_type: str + payload: dict | None = None + is_read: bool + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class Event(BaseModel): + """WebSocket 推送的实时事件""" + event_type: str + payload: dict + timestamp: str + + model_config = ConfigDict(from_attributes=True) + + +class Organization(BaseModel): + """组织模型""" + id: str + name: str + description: str | None + created_by: str + created_at: datetime + member_count: int = 0 + + model_config = ConfigDict(from_attributes=True) + + +class OrgMember(BaseModel): + """组织成员模型""" + worker_id: str + worker_name: str + worker_type: str + role: str # "admin" | "member" + joined_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class OrgInvite(BaseModel): + """组织邀请码模型""" + id: str + org_id: str + code: str + created_by: str + expires_at: datetime | None + max_uses: int | None + used_count: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/main.py b/main.py new file mode 100644 index 0000000..0deebd7 --- /dev/null +++ b/main.py @@ -0,0 +1,69 @@ +""" +DeepSeek Web Adapter - 主入口 +启动 Adapter 连接 Array 平台 + 控制面板 +""" +import asyncio +import logging +import sys +import threading +import signal + +from src.config import config +from src.adapter import DeepSeekWebAdapter +from src.admin_server import AdminServer + +logging.basicConfig( + level=getattr(logging, config.log_level, logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("adapter_debug.log", encoding="utf-8"), + ], +) +logger = logging.getLogger(__name__) + + +def main(): + logger.info("=" * 60) + logger.info(" DeepSeek Web Adapter - 启动中...") + logger.info("=" * 60) + + adapter = DeepSeekWebAdapter() + admin = AdminServer(adapter) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + admin.set_adapter(adapter, loop) + + admin_thread = threading.Thread( + target=admin.run, + kwargs={"host": "0.0.0.0", "port": config.admin_port}, + daemon=True, + ) + admin_thread.start() + logger.info(f"控制面板已启动: http://localhost:{config.admin_port}") + + def shutdown(sig=None, frame=None): + logger.info("收到关闭信号,正在退出...") + loop.call_soon_threadsafe(loop.stop) + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + logger.info("正在连接 Array 平台...") + try: + loop.run_until_complete(adapter.run()) + except KeyboardInterrupt: + logger.info("用户中断,正在退出...") + except Exception as e: + logger.error(f"Adapter 运行异常: {e}", exc_info=True) + finally: + if adapter.session: + loop.run_until_complete(adapter.stop_deepseek_session()) + loop.close() + logger.info("DeepSeek Web Adapter 已关闭") + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..d42248b --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +from .config import config, DeepSeekWebConfig +from .adapter import DeepSeekWebAdapter +from .deepseek_agent_adapter import DeepSeekSession, ADAPTER_SYSTEM_PROMPT diff --git a/src/adapter.py b/src/adapter.py new file mode 100644 index 0000000..638b81f --- /dev/null +++ b/src/adapter.py @@ -0,0 +1,351 @@ +""" +DeepSeek Web Adapter - 核心 Adapter 逻辑 +通过 Playwright 控制 DeepSeek 网页版,接入列阵(Array)协作平台 +""" +import asyncio +import json +import logging +import re +import time +from typing import Optional, Dict + +from array_sdk import BaseAdapter + +from .config import config +from .deepseek_agent_adapter import DeepSeekSession, ADAPTER_SYSTEM_PROMPT + +logger = logging.getLogger(__name__) + + +class DeepSeekWebAdapter(BaseAdapter): + """ + DeepSeek Web 适配器 + + 流程: + 1. 收到 Array 平台消息 -> 转发给 DeepSeek 网页 + 2. DeepSeek 回复中包含 ACTION -> 解析执行 + 3. reply 类型的 ACTION -> 立即回传 Array 平台 + 4. 其他 ACTION -> 执行本地命令 -> 结果喂回 DeepSeek + 5. 循环直到 DeepSeek 输出 complete + """ + + def __init__(self): + super().__init__( + server_url=config.array_base_url, + api_key=config.api_key + ) + self.session: Optional[DeepSeekSession] = None + self._current_task_id: Optional[str] = None + self._current_sender_id: Optional[str] = None + self._current_scope: str = "chat" + self._current_private_to_id: Optional[str] = None + self._processing = False + self._task_lock = asyncio.Lock() + + async def on_connected(self): + logger.info("=== DeepSeek Web Adapter 已连接到 Array 平台 ===") + try: + await self.client.update_profile(**config.profile) + logger.info("Profile 更新成功") + except Exception as e: + logger.warning(f"Profile 更新失败: {e}") + + if not self.session: + logger.info("DeepSeek 浏览器会话尚未启动,请在控制面板中启动") + + async def on_disconnected(self): + logger.warning("=== DeepSeek Web Adapter 与 Array 平台断开连接 ===") + + async def on_pending_recovery(self, tasks: list): + logger.info(f"收到 {len(tasks)} 个待恢复任务") + for task_info in tasks: + task_id = task_info.get("id", "") + status = task_info.get("status", "") + logger.info(f" 待恢复任务: {task_id}, status={status}") + + async def on_task_invited(self, task): + logger.info(f"[任务邀请] task_id={task.id}, title={task.title}") + try: + await self.client.add_participants(task.id, [self.client.worker_id]) + logger.info(f"已接受任务邀请: {task.id}") + except Exception as e: + logger.warning(f"接受任务邀请失败: {e}") + + async def on_task_message(self, task_id: str, payload: dict): + msg_type = payload.get("type") + sender_id = payload.get("sender_id") + sender_name = payload.get("sender_name", "Unknown") + content = payload.get("content", "") + scope = payload.get("scope", "chat") + private_to_id = payload.get("private_to_id") + + is_private_to_me = private_to_id == self.client.worker_id + is_public = private_to_id is None + is_private_to_other = private_to_id is not None and private_to_id != self.client.worker_id + + logger.info( + f"[收到消息] task={task_id}, from={sender_name}, " + f"type={msg_type}, scope={scope}, private_to_me={is_private_to_me}, " + f"content={content[:80]}..." + ) + + if msg_type != "comment": + return + + if sender_id == self.client.worker_id: + return + + if is_private_to_other: + return + + if is_public and "chat" not in config.allowed_scopes: + return + + if is_private_to_me and "private" not in config.allowed_scopes: + return + + is_cmd, cmd_type = self._check_system_command(content) + if is_cmd: + await self._handle_system_command(task_id, sender_id, cmd_type, scope, private_to_id) + return + + self._current_task_id = task_id + self._current_sender_id = sender_id + self._current_scope = scope + self._current_private_to_id = private_to_id if is_private_to_me else None + + if not self.session or not self.session.is_ready(): + await self._reply(task_id, sender_id, + "DeepSeek 浏览器会话未启动,请先在控制面板中启动浏览器并登录 DeepSeek", + scope, private_to_id) + return + + async with self._task_lock: + if self._processing: + await self._reply(task_id, sender_id, + "当前有任务正在处理中,请稍后再试", + scope, private_to_id) + return + + self._processing = True + try: + await self._process_task(task_id, sender_id, sender_name, content, scope, private_to_id) + except Exception as e: + logger.error(f"处理任务失败: {e}", exc_info=True) + await self._reply(task_id, sender_id, f"处理失败: {e}", scope, private_to_id) + finally: + self._processing = False + + async def on_task_updated(self, task_id: str, changes: dict): + new_status = changes.get("status") + if new_status in ("completed", "cancelled", "closed"): + logger.info(f"[任务关闭] task={task_id}, status={new_status}") + if self.session: + self.session.cancel_current_task() + + async def _process_task( + self, + task_id: str, + sender_id: str, + sender_name: str, + content: str, + scope: str = "chat", + private_to_id: Optional[str] = None, + ): + logger.info(f"[处理任务] {sender_name}: {content[:100]}...") + + full_content = f"[{sender_name}]: {content}" + await self.session.send_message(full_content) + + round_count = 0 + max_rounds = config.max_rounds + + while round_count < max_rounds: + round_count += 1 + + response = await self.session.wait_for_response(timeout=config.task_timeout) + if not response: + await self._reply(task_id, sender_id, "DeepSeek 响应超时", scope, private_to_id) + return + + action = self._parse_action(response) + + if action is None: + if "<<>>" in response: + logger.warning("ACTION 解析失败,返回错误给 DeepSeek") + error_msg = ( + f"[执行结果]\n错误: ACTION 格式解析失败,请重新输出正确的 ACTION。\n\n" + f"本次回复请以 <<>> 结尾" + ) + await self.session.send_message(error_msg) + continue + + clean_msg = self._clean_action_markers(response) + if clean_msg.strip(): + await self._reply(task_id, sender_id, clean_msg, scope, private_to_id) + return + + action_type = action.get("type", "") + + if action_type == "reply": + reply_content = action.get("content", "") + reply_status = action.get("status", "received") + logger.info(f"[reply] status={reply_status}, content={reply_content[:80]}...") + + await self._reply(task_id, sender_id, reply_content, scope, private_to_id) + + if reply_status == "completed": + logger.info(f"[任务完成] task={task_id}") + return + + result_msg = f"[执行结果]\n回复已发送给用户(状态: {reply_status})\n\n请继续下一步操作。" + await self.session.send_message(result_msg) + continue + + if action_type == "complete": + explanation = action.get("explanation", "任务已完成") + logger.info(f"[任务完成] task={task_id}") + await self._reply(task_id, sender_id, explanation, scope, private_to_id) + return + + cmd = action.get("cmd", "") + path = action.get("path", "") + action_content = action.get("content", "") + explanation = action.get("explanation", "") + + if action_type == "write_file" and path: + cmd = path + + logger.info(f"[执行] [{action_type}] {cmd or path} {explanation}") + + result = await self.session.execute_action(action_type, cmd, action_content) + + status = "[OK]" if result["success"] else "[ERR]" + output_preview = result["output"][:200].replace("\n", " ") + logger.info(f" {status} {output_preview}...") + + display_cmd = path if action_type == "write_file" and path else cmd + result_msg = ( + f"[执行结果]\n" + f"操作: {action_type}\n" + f"命令: {display_cmd}\n" + f"成功: {result['success']}\n\n" + f"输出:\n```\n{result['output']}\n```\n\n" + f"请继续下一步或完成任务。" + ) + + await self.session.send_message(result_msg) + + await self._reply(task_id, sender_id, "已达到最大交互轮数,任务终止", scope, private_to_id) + + def _parse_action(self, text: str) -> Optional[dict]: + if not text: + return None + + action_pattern = r'<<>>' + action_match = re.search(action_pattern, text, re.DOTALL) + + if not action_match: + if "<<>>" in text: + try: + start = text.find("<<>>") + json_content = text[start:end].strip() + if json_content.startswith("{") and json_content.endswith("}"): + action_match = type('obj', (object,), {'group': lambda x, s=json_content: s})() + except Exception: + return None + else: + return None + + try: + json_str = action_match.group(1).strip() + action = json.loads(json_str) + + if action.get("type") == "write_file": + content_pattern = r'<<>>' + content_match = re.search(content_pattern, text, re.DOTALL) + if content_match: + action["content"] = content_match.group(1) + else: + action["content"] = "" + + return action + except json.JSONDecodeError as e: + logger.warning(f"JSON 解析失败: {e}") + return None + + def _clean_action_markers(self, text: str) -> str: + clean = re.sub(r'<<>>', '', text, flags=re.DOTALL) + clean = re.sub(r'<<>>', '', clean, flags=re.DOTALL) + clean = clean.replace("<<>>", "") + clean = re.sub(r'\n{3,}', '\n\n', clean) + return clean.strip() + + def _check_system_command(self, content: str) -> tuple: + match = re.match(r"^\[系统指令[::](.+?)\]$", content.strip()) + if match: + return True, match.group(1) + return False, "" + + async def _handle_system_command(self, task_id, sender_id, cmd, scope, private_to_id): + if cmd == "取消": + if self.session: + self.session.cancel_current_task() + await self._reply(task_id, sender_id, "已取消当前任务", scope, private_to_id) + elif cmd == "状态": + status_parts = [] + if self.session and self.session.is_ready(): + status_parts.append("DeepSeek 浏览器: 已连接") + else: + status_parts.append("DeepSeek 浏览器: 未启动") + status_parts.append(f"当前任务: {'处理中' if self._processing else '空闲'}") + await self._reply(task_id, sender_id, "\n".join(status_parts), scope, private_to_id) + else: + await self._reply(task_id, sender_id, f"未知指令: {cmd}", scope, private_to_id) + + async def _reply(self, task_id, user_id, message, scope="chat", private_to_id=None): + try: + if private_to_id and private_to_id == self.client.worker_id: + await self.client.send_message( + task_id=task_id, content=message, private_to_id=user_id + ) + elif scope == "private" and user_id: + await self.client.send_message( + task_id=task_id, content=message, private_to_id=user_id + ) + elif config.reply_scope == "private" and user_id: + await self.client.send_message( + task_id=task_id, content=message, private_to_id=user_id + ) + else: + await self.client.send_message( + task_id=task_id, content=message, scope="chat" + ) + logger.info(f"[回复] {message[:60]}...") + except Exception as e: + logger.error(f"发送回复失败: {e}") + + async def start_deepseek_session(self) -> bool: + try: + self.session = DeepSeekSession() + await self.session.start() + return True + except Exception as e: + logger.error(f"启动 DeepSeek 会话失败: {e}", exc_info=True) + self.session = None + return False + + async def stop_deepseek_session(self): + if self.session: + await self.session.stop() + self.session = None + + def get_status(self) -> dict: + return { + "deepseek_ready": self.session is not None and self.session.is_ready(), + "processing": self._processing, + "current_task": self._current_task_id, + "connected": self.client is not None, + "worker_id": self.client.worker_id if self.client else None, + } diff --git a/src/admin_server.py b/src/admin_server.py new file mode 100644 index 0000000..3bfb195 --- /dev/null +++ b/src/admin_server.py @@ -0,0 +1,194 @@ +""" +DeepSeek Web Adapter 控制面板 +提供 Web UI 管理:配置、Worker 注册、浏览器控制、日志查看 +""" +import asyncio +import json +import logging +import threading +from pathlib import Path +from typing import Optional + +from flask import Flask, render_template, request, jsonify +from flask_socketio import SocketIO + +from .config import config, DeepSeekWebConfig, CONFIG_FILE + +logger = logging.getLogger(__name__) + +TEMPLATE_DIR = str(Path(__file__).parent.parent / "templates") + + +class AdminServer: + """控制面板 HTTP 服务""" + + def __init__(self, adapter=None): + self.adapter = adapter + self.app = Flask(__name__, template_folder=TEMPLATE_DIR, static_folder=None) + self.socketio = SocketIO(self.app, cors_allowed_origins="*", async_mode="threading") + self._adapter_loop = None + self._setup_routes() + + def _setup_routes(self): + @self.app.route("/") + def index(): + return render_template("admin.html") + + @self.app.route("/api/config", methods=["GET"]) + def get_config(): + from dataclasses import asdict + return jsonify(asdict(config)) + + @self.app.route("/api/config", methods=["POST"]) + def save_config(): + try: + data = request.json + for key, value in data.items(): + if hasattr(config, key): + setattr(config, key, value) + config.save() + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}) + + @self.app.route("/api/register", methods=["POST"]) + def register_worker(): + try: + data = request.json + import urllib.request + import ssl + + base_url = config.array_base_url.rstrip("/") + url = f"{base_url}/api/v1/workers/register" + + reg_data = { + "name": data.get("name", "DeepSeek-Web"), + "type": data.get("type", "ai"), + "description": data.get("description", ""), + "invite_code": data.get("invite_code") or None, + "capabilities": config.profile.get("capabilities", {}), + "organization": "Array", + "timezone": "Asia/Shanghai", + "tags": ["AI", "DeepSeek", "Agent", "Web"], + "max_concurrent_tasks": 1, + } + + req = urllib.request.Request( + url, + data=json.dumps(reg_data).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + try: + with urllib.request.urlopen(req, context=ctx, timeout=10) as resp: + result = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + error_body = e.read().decode("utf-8", errors="replace") + return jsonify({"error": f"HTTP {e.code}: {error_body}"}) + + worker_id = result.get("worker", {}).get("id", "") + api_key = result.get("api_key", "") + + if api_key: + config.api_key = api_key + config.worker_id = worker_id + config.worker_name = data.get("name", config.worker_name) + config.save() + + return jsonify({"worker_id": worker_id, "api_key": api_key}) + except Exception as e: + return jsonify({"error": str(e)}) + + @self.app.route("/api/status") + def get_status(): + if self.adapter: + status = self.adapter.get_status() + status["worker_name"] = config.worker_name + return jsonify(status) + return jsonify({ + "connected": False, "deepseek_ready": False, + "processing": False, "current_task": None, + "worker_id": None, "worker_name": config.worker_name, + }) + + @self.app.route("/api/browser/start", methods=["POST"]) + def start_browser(): + if not self.adapter or not self._adapter_loop: + return jsonify({"success": False, "error": "Not ready"}) + future = asyncio.run_coroutine_threadsafe( + self.adapter.start_deepseek_session(), self._adapter_loop + ) + try: + result = future.result(timeout=30) + return jsonify({"success": result}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}) + + @self.app.route("/api/browser/stop", methods=["POST"]) + def stop_browser(): + if not self.adapter or not self._adapter_loop: + return jsonify({"success": False, "error": "Not ready"}) + future = asyncio.run_coroutine_threadsafe( + self.adapter.stop_deepseek_session(), self._adapter_loop + ) + try: + future.result(timeout=10) + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}) + + @self.app.route("/api/browser/reinit", methods=["POST"]) + def reinit_prompt(): + if not self.adapter or not self.adapter.session or not self._adapter_loop: + return jsonify({"success": False, "error": "DeepSeek session not started"}) + from .deepseek_agent_adapter import ADAPTER_SYSTEM_PROMPT + async def _reinit(): + await self.adapter.session.controller.send( + f"[系统重新初始化]\n\n{ADAPTER_SYSTEM_PROMPT}" + ) + return True + future = asyncio.run_coroutine_threadsafe(_reinit(), self._adapter_loop) + try: + future.result(timeout=15) + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}) + + @self.app.route("/api/logs/deepseek") + def get_deepseek_logs(): + if self.adapter and self.adapter.session: + return jsonify({"logs": self.adapter.session.get_logs(100)}) + return jsonify({"logs": None}) + + @self.app.route("/api/logs/deepseek", methods=["DELETE"]) + def clear_deepseek_logs(): + if self.adapter and self.adapter.session: + self.adapter.session._log_buffer.clear() + return jsonify({"success": True}) + + @self.app.route("/api/logs/system") + def get_system_logs(): + log_file = Path("adapter_debug.log") + if log_file.exists(): + try: + content = log_file.read_text(encoding="utf-8", errors="replace") + lines = content.split("\n") + return jsonify({"logs": "\n".join(lines[-200:])}) + except: + pass + return jsonify({"logs": None}) + + def set_adapter(self, adapter, loop): + self.adapter = adapter + self._adapter_loop = loop + + def run(self, host="0.0.0.0", port=None): + if port is None: + port = config.admin_port + logger.info(f"控制面板启动: http://{host}:{port}") + self.socketio.run(self.app, host=host, port=port, debug=False, allow_unsafe_werkzeug=True) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..a3073c5 --- /dev/null +++ b/src/config.py @@ -0,0 +1,126 @@ +""" +DeepSeek Web Adapter 配置管理 +""" +import json +import os +import logging +import tempfile +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Optional, List + +logger = logging.getLogger(__name__) + +CONFIG_DIR = Path.home() / ".deepseek_web_adapter" +CONFIG_FILE = CONFIG_DIR / "config.json" + + +@dataclass +class DeepSeekWebConfig: + array_base_url: str = "http://47.92.218.141:8000" + api_key: str = "" + worker_id: str = "" + worker_name: str = "DeepSeek-Web" + worker_type: str = "ai" + worker_location: str = "" + admin_port: int = 9091 + log_level: str = "INFO" + max_rounds: int = 30 + task_timeout: int = 600 + deepseek_url: str = "https://chat.deepseek.com" + headless: bool = False + user_data_dir: str = "" + allowed_scopes: List[str] = field(default_factory=lambda: ["private", "chat"]) + reply_scope: str = "private" + + def __post_init__(self): + if not self.user_data_dir: + self.user_data_dir = str(Path.home() / ".deepseek_chat_profile") + + @property + def profile(self) -> dict: + return { + "name": self.worker_name, + "type": self.worker_type, + "description": ( + "DeepSeek 网页版 AI Agent,通过 Playwright 控制浏览器与 DeepSeek 网页交互," + "支持本地命令执行、文件读写、目录浏览等操作。" + "收到任务后会先回复确认,再逐步执行,过程中实时汇报进度。" + "内置系统指令:[系统指令:取消] 终止任务,[系统指令:状态] 查看状态。" + ), + "capabilities": { + "skills": [ + "command_execution", "file_operations", "code_generation", + "text_analysis", "web_research", "data_processing" + ], + "domains": ["general", "software_development"], + "tools": ["powershell", "python", "playwright"], + "models": ["deepseek"], + "languages": ["zh", "en"], + "custom": { + "controlled_app": { + "name": "DeepSeek Web", + "vendor": "DeepSeek", + "description": "DeepSeek 网页版 AI 助手", + }, + "control_method": { + "protocol": "Playwright", + "description": "通过 Playwright 控制 Chrome 浏览器与 DeepSeek 网页交互" + }, + "message_types": self.allowed_scopes, + "max_concurrent_tasks": 1, + "system_commands": { + "取消": "终止当前任务", + "状态": "返回当前任务状态", + } + } + }, + "organization": "Array", + "location": self.worker_location, + "timezone": "Asia/Shanghai", + "tags": ["AI", "DeepSeek", "Agent", "Web"], + "max_concurrent_tasks": 1, + } + + def save(self): + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + data = asdict(self) + tmp_fd, tmp_path = tempfile.mkstemp( + suffix=".tmp", prefix="cfg_", dir=str(CONFIG_DIR), text=True + ) + 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(CONFIG_FILE)) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + logger.info(f"配置已保存到 {CONFIG_FILE}") + + @classmethod + def load(cls) -> "DeepSeekWebConfig": + if not CONFIG_FILE.exists(): + return cls() + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) + except Exception as e: + logger.warning(f"加载配置失败: {e},使用默认配置") + return cls() + + @classmethod + def from_env(cls) -> "DeepSeekWebConfig": + cfg = cls() + cfg.array_base_url = os.getenv("ARRAY_BASE_URL", cfg.array_base_url) + cfg.api_key = os.getenv("ARRAY_API_KEY", cfg.api_key) + cfg.worker_id = os.getenv("ARRAY_WORKER_ID", cfg.worker_id) + cfg.admin_port = int(os.getenv("ADMIN_PORT", str(cfg.admin_port))) + cfg.log_level = os.getenv("LOG_LEVEL", cfg.log_level) + return cfg + + +config = DeepSeekWebConfig.load() diff --git a/src/deepseek_agent_adapter.py b/src/deepseek_agent_adapter.py new file mode 100644 index 0000000..25f4c3f --- /dev/null +++ b/src/deepseek_agent_adapter.py @@ -0,0 +1,656 @@ +""" +DeepSeek 会话封装层 +将 Playwright 控制器 + 执行引擎封装为可被 Adapter 调用的会话对象 +""" +import asyncio +import json +import logging +import os +import re +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Optional + +from playwright.async_api import async_playwright, Page, BrowserContext + +from .config import config + +logger = logging.getLogger(__name__) + +DEEPSEEK_URL = config.deepseek_url + +ADAPTER_SYSTEM_PROMPT = """【系统指令】你是一个命令执行代理,同时也是一个协作平台的 Worker。你的职责是根据用户指令执行本地命令,并将执行结果返回给用户。 + +═══════════════════════════════════════════════════════ +【系统架构 - 重要!】 +═══════════════════════════════════════════════════════ +本系统采用【双终端架构】: +• 【DP对话终端】- 你当前所在的终端,用于和我(系统)对话 +• 【命令执行终端】- 独立的shell进程,专门用于执行所有命令 + +关键机制: +1. command/powershell 命令会发送到【命令执行终端】执行 +2. 命令执行是【非阻塞】的 - 命令扔进去立即返回,不等待完成 +3. 命令执行终端的输出会【持续累积】,随时可以获取 +4. 你需要用 get_terminal_output 主动查询命令执行结果 + +═══════════════════════════════════════════════════════ +【可用命令】 +═══════════════════════════════════════════════════════ +• reply <内容> <状态> - 向用户回复消息(重要!) + 状态: received=收到任务, progress=进行中, question=有疑问, completed=已完成 +• command <命令> - 在命令执行终端执行命令(非阻塞) +• powershell <命令> - 在命令执行终端执行PowerShell(非阻塞) +• get_terminal_output - 获取命令执行终端的所有输出 +• send_input <内容> - 向命令执行终端发送输入(用于交互式程序) +• clear_terminal - 清空命令执行终端的输出记录 +• read_file <路径> - 读取文件内容 +• write_file <路径> - 写入文件(配合 << - 列出目录内容 +• search <关键词> - 搜索文件内容 +• complete - 标记任务完成 + +═══════════════════════════════════════════════════════ +【铁律 - 必须遵守!】 +═══════════════════════════════════════════════════════ +!!!!!!! 收到任务后必须立即执行 reply 回复用户!!!!!!! +!!!!!!! 绝对不允许先工作后回复!!!!!!! +!!!!!!! 必须先回复再工作!!!!!!! + +正确流程: + 第1步:【立即】reply received "收到/稍等/我来看看" + 第2步:然后才开始处理任务 + 第3步:工作过程中随时 reply progress "正在xxx" + 第4步:有疑问时 reply question "想确认一下..." + 第5步:完成时 reply completed "任务结果..." + +═══════════════════════════════════════════════════════ +【执行流程 - 必须遵守】 +═══════════════════════════════════════════════════════ +第1步:发送 command 执行命令 + ↓ +第2步:等待系统返回(命令已发送,不等待完成) + ↓ +第3步:发送 get_terminal_output 查询输出 + ↓ +第4步:根据输出判断: + - 命令已完成 → 继续下一步 + - 命令在运行中 → 等待后再次 get_terminal_output + - 命令在等待输入 → 用 send_input 发送输入 + - 命令出错 → 处理错误 + +═══════════════════════════════════════════════════════ +【响应格式 - 必须严格遵守】 +═══════════════════════════════════════════════════════ +每次响应必须包含两部分: + +1. ACTION 块(如需执行命令): + <<>> + + reply 命令格式: + <<>> + +2. 结束标记(必须!): + <<>> + +═══════════════════════════════════════════════════════ +【重要规则】 +═══════════════════════════════════════════════════════ +1. 【必须先回复】收到任务后,必须先执行 reply received,再开始工作 +2. 【必须加结束标记】每次回复的最后一行必须是 <<>> +3. 【非阻塞执行】command/powershell 不等待命令完成,立即返回 +4. 【必须查询输出】执行命令后,必须用 get_terminal_output 查看结果 +5. 【交互式程序】如果程序等待输入,用 send_input 发送输入,不要假设程序已结束 +6. 【文件编码】write_file 配合 <<>> +<<>> + +你(第2轮 - 执行命令): +<<>> +<<>> + +你(第3轮 - 查看输出): +<<>> +<<>> + +你(第4轮 - 汇报完成): +<<>> +<<>> + +═══════════════════════════════════════════════════════ +【示例 - 写入文件(必须用代码围栏)】 +═══════════════════════════════════════════════════════ +<<>> +<<>> +<<>> + +═══════════════════════════════════════════════════════ +【示例 - 有疑问时】 +═══════════════════════════════════════════════════════ +<<>> +<<>> +""" + + +class ExecutionEngine: + """本地命令执行引擎""" + + def __init__(self): + self.cmd_terminal_proc: Optional[asyncio.subprocess.Process] = None + self.cmd_terminal_output: str = "" + self.cmd_terminal_lock = asyncio.Lock() + self.last_cmd: str = "" + + async def _ensure_cmd_terminal(self) -> asyncio.subprocess.Process: + if self.cmd_terminal_proc is None or self.cmd_terminal_proc.returncode is not None: + if sys.platform == "win32": + self.cmd_terminal_proc = await asyncio.create_subprocess_exec( + "powershell", "-NoLogo", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + stdin=asyncio.subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW + ) + await asyncio.sleep(0.2) + self.cmd_terminal_proc.stdin.write( + b"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" + ) + await self.cmd_terminal_proc.stdin.drain() + else: + self.cmd_terminal_proc = await asyncio.create_subprocess_exec( + "bash", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + stdin=asyncio.subprocess.PIPE + ) + asyncio.create_task(self._read_cmd_terminal_output()) + logger.info(f"命令执行终端已启动 (PID: {self.cmd_terminal_proc.pid})") + return self.cmd_terminal_proc + + async def _read_cmd_terminal_output(self): + while self.cmd_terminal_proc and self.cmd_terminal_proc.returncode is None: + try: + line = await asyncio.wait_for( + self.cmd_terminal_proc.stdout.readline(), + timeout=0.1 + ) + if line: + async with self.cmd_terminal_lock: + decoded = line.decode('utf-8', errors='replace') + self.cmd_terminal_output += decoded + except asyncio.TimeoutError: + continue + except Exception: + break + + async def execute(self, action_type: str, command: str, content: str = "") -> dict: + cwd = Path.home() + + handlers = { + "command": self._exec_in_cmd_terminal, + "powershell": self._exec_in_cmd_terminal, + "read_file": self._read_file, + "write_file": self._write_file, + "list_dir": self._list_dir, + "search": self._search, + "get_terminal_output": self._get_terminal_output, + "send_input": self._send_input_to_terminal, + "clear_terminal": self._clear_terminal_output, + } + + handler = handlers.get(action_type) + if not handler: + return {"success": False, "output": f"Unknown action type: {action_type}"} + + try: + if action_type == "write_file": + if asyncio.iscoroutinefunction(handler): + result = await handler(command, cwd, content) + else: + result = handler(command, cwd, content) + else: + if asyncio.iscoroutinefunction(handler): + result = await handler(command, cwd) + else: + result = handler(command, cwd) + return {"success": True, "output": result} + except Exception as e: + return {"success": False, "output": f"Error: {e}"} + + async def _exec_in_cmd_terminal(self, cmd: str, cwd: Path) -> str: + proc = await self._ensure_cmd_terminal() + + async with self.cmd_terminal_lock: + output_before_len = len(self.cmd_terminal_output) + + self.last_cmd = cmd + if sys.platform == "win32": + if cmd.strip().startswith("python"): + full_cmd = f"cd '{cwd}'; $env:PYTHONIOENCODING='utf-8'; {cmd}\n" + else: + full_cmd = f"cd '{cwd}'; {cmd}\n" + else: + full_cmd = f"cd '{cwd}' && {cmd}\n" + + proc.stdin.write(full_cmd.encode()) + await proc.stdin.drain() + await asyncio.sleep(0.5) + + async with self.cmd_terminal_lock: + current_output = self.cmd_terminal_output[output_before_len:] + + return ( + f"[已执行] {cmd}\n\n命令终端当前输出:\n" + f"```\n{current_output[-2000:] if len(current_output) > 2000 else current_output}\n```\n\n" + f"提示: 使用 'get_terminal_output' 查看最新输出" + ) + + async def _get_terminal_output(self, _: str, cwd: Path) -> str: + async with self.cmd_terminal_lock: + output = self.cmd_terminal_output + if len(output) > 5000: + return f"命令终端输出 (共 {len(output)} 字符,显示最后5000字符):\n```\n...\n{output[-5000:]}\n```" + else: + return f"命令终端输出 (共 {len(output)} 字符):\n```\n{output}\n```" + + async def _send_input_to_terminal(self, input_text: str, cwd: Path) -> str: + if not self.cmd_terminal_proc or self.cmd_terminal_proc.returncode is not None: + return "错误: 命令终端未启动" + self.cmd_terminal_proc.stdin.write(input_text.encode() + b'\n') + await self.cmd_terminal_proc.stdin.drain() + await asyncio.sleep(0.3) + async with self.cmd_terminal_lock: + output = self.cmd_terminal_output + return f"[已发送输入] {input_text}\n\n终端最新输出:\n```\n{output[-1500:] if len(output) > 1500 else output}\n```" + + async def _clear_terminal_output(self, _: str, cwd: Path) -> str: + async with self.cmd_terminal_lock: + old_len = len(self.cmd_terminal_output) + self.cmd_terminal_output = "" + return f"已清空命令终端输出记录 (清除了 {old_len} 字符)" + + def _read_file(self, path: str, cwd: Path) -> str: + target = cwd / path if not Path(path).is_absolute() else Path(path) + if not target.exists(): + return f"File not found: {target}" + try: + return target.read_text(encoding='utf-8', errors='replace')[:5000] + except Exception as e: + return f"Read error: {e}" + + def _write_file(self, cmd: str, cwd: Path, content: str = "") -> str: + if "::" in cmd and not content: + path, content = cmd.split("::", 1) + else: + path = cmd + + content = content.strip() + if content.startswith('```'): + first_newline = content.find('\n') + if first_newline != -1: + content = content[first_newline + 1:] + if content.rstrip().endswith('```'): + last_backticks = content.rstrip().rfind('```') + content = content[:last_backticks].rstrip() + + target = cwd / path if not Path(path).is_absolute() else Path(path) + try: + target.parent.mkdir(parents=True, exist_ok=True) + tmp_fd, tmp_path = tempfile.mkstemp( + suffix=".tmp", prefix="dw_", dir=str(target.parent), text=True + ) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp_path, str(target)) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + return f"Written to {target} ({len(content)} chars)" + except Exception as e: + return f"Write error: {e}" + + def _list_dir(self, path: str, cwd: Path) -> str: + target = cwd / path if not Path(path).is_absolute() else Path(path) + if not target.exists(): + return f"Directory not found: {target}" + try: + items = [] + for item in target.iterdir(): + item_type = "[DIR]" if item.is_dir() else "[FILE]" + size = "" + if item.is_file(): + try: + size_bytes = item.stat().st_size + if size_bytes < 1024: + size = f" ({size_bytes}B)" + elif size_bytes < 1024 * 1024: + size = f" ({size_bytes / 1024:.1f}KB)" + else: + size = f" ({size_bytes / (1024 * 1024):.1f}MB)" + except: + pass + items.append(f"{item_type} {item.name}{size}") + return "\n".join(items) if items else "(empty directory)" + except Exception as e: + return f"List error: {e}" + + def _search(self, pattern: str, cwd: Path) -> str: + results = [] + try: + for item in cwd.rglob("*"): + if item.is_file() and pattern.lower() in item.name.lower(): + results.append(str(item.relative_to(cwd))) + if len(results) >= 20: + results.append("... (more results)") + break + return "\n".join(results) if results else "No matches found" + except Exception as e: + return f"Search error: {e}" + + async def cleanup(self): + if self.cmd_terminal_proc and self.cmd_terminal_proc.returncode is None: + try: + self.cmd_terminal_proc.terminate() + await asyncio.sleep(0.5) + if self.cmd_terminal_proc.returncode is None: + self.cmd_terminal_proc.kill() + except Exception: + pass + + +class DeepSeekController: + """DeepSeek 页面控制器""" + + def __init__(self, page: Page): + self.page = page + + async def send(self, message: str) -> bool: + try: + input_selectors = [ + 'textarea[placeholder*="输入"]', + 'textarea[class*="input"]', + '[class*="input"] textarea', + 'div[contenteditable="true"]', + 'textarea', + ] + + input_box = None + for selector in input_selectors: + try: + el = self.page.locator(selector).first + if await el.count() > 0 and await el.is_visible(): + input_box = el + break + except: + continue + + if not input_box: + logger.error("找不到 DeepSeek 输入框") + return False + + await input_box.fill(message) + await asyncio.sleep(0.3) + + send_btn = self.page.locator("button[class*='send']").last + if await send_btn.count() > 0 and await send_btn.is_visible(): + await send_btn.click() + else: + await input_box.press("Enter") + + return True + except Exception as e: + logger.error(f"发送消息到 DeepSeek 失败: {e}") + return False + + async def get_latest_ai_message(self) -> Optional[str]: + try: + text = await self.page.evaluate(""" + () => { + function extractMessageContent(msg) { + const markdownContent = msg.querySelector('[class*="markdown"], [class*="ds-markdown"]'); + if (!markdownContent) { + return msg.textContent.trim(); + } + + let result = ''; + const children = markdownContent.children; + for (const child of children) { + if (child.tagName === 'DIV' && child.querySelector('pre')) { + const pre = child.querySelector('pre'); + const code = pre.querySelector('code'); + const codeText = code ? code.textContent : pre.textContent; + result += '\\n```python\\n' + codeText + '\\n```\\n'; + } else if (child.tagName === 'P') { + result += child.textContent + '\\n'; + } else { + result += child.textContent + '\\n'; + } + } + return result.trim(); + } + + const messageContainers = [ + ...document.querySelectorAll('[class*="ds-message"]'), + ...document.querySelectorAll('[class*="message"]'), + ]; + + if (messageContainers.length === 0) return null; + + for (let i = messageContainers.length - 1; i >= 0; i--) { + const msg = messageContainers[i]; + const content = extractMessageContent(msg); + if (content.includes('<<>>')) { + return content; + } + } + + const lastMsg = messageContainers[messageContainers.length - 1]; + return extractMessageContent(lastMsg); + } + """) + return text + except Exception as e: + logger.error(f"获取 DeepSeek 消息失败: {e}") + return None + + async def is_generating(self) -> bool: + try: + stop_btn = self.page.locator("button[class*='stop'], button[aria-label*='stop']").first + if await stop_btn.count() > 0 and await stop_btn.is_visible(): + return True + return False + except: + return False + + +class DeepSeekSession: + """DeepSeek 浏览器会话 - 封装 Playwright 控制和命令执行""" + + def __init__(self): + self.controller: Optional[DeepSeekController] = None + self.engine = ExecutionEngine() + self.page: Optional[Page] = None + self._playwright = None + self._browser = None + self._context = None + self._initialized = False + self._last_ai_msg = "" + self._cancelled = False + self._log_buffer = [] + + def is_ready(self) -> bool: + return self._initialized and self.controller is not None and self.page is not None + + async def start(self) -> bool: + if self._initialized: + return True + + try: + self._playwright = await async_playwright().start() + + user_data_dir = Path(config.user_data_dir).expanduser() + user_data_dir.mkdir(parents=True, exist_ok=True) + + try: + self._context = await self._playwright.chromium.launch_persistent_context( + str(user_data_dir), + channel="chrome", + headless=config.headless, + args=["--start-maximized"], + no_viewport=True + ) + logger.info("使用持久化会话,保留登录状态") + except Exception as e: + logger.warning(f"持久化会话启动失败: {e},使用普通模式") + self._browser = await self._playwright.chromium.launch( + channel="chrome", + headless=config.headless, + args=["--start-maximized"] + ) + self._context = await self._browser.new_context(no_viewport=True) + + self.page = self._context.pages[0] if self._context.pages else await self._context.new_page() + await self.page.goto(DEEPSEEK_URL) + + logger.info(f"已打开 DeepSeek: {DEEPSEEK_URL}") + logger.info("等待用户登录 DeepSeek...") + + self.controller = DeepSeekController(self.page) + + logger.info("发送系统提示词到 DeepSeek...") + await self.controller.send(f"[系统初始化]\n\n{ADAPTER_SYSTEM_PROMPT}") + + await asyncio.sleep(3) + + self._initialized = True + logger.info("DeepSeek 会话初始化完成") + return True + + except Exception as e: + logger.error(f"启动 DeepSeek 会话失败: {e}", exc_info=True) + await self.stop() + return False + + async def stop(self): + await self.engine.cleanup() + if self._context: + try: + await self._context.close() + except: + pass + self._context = None + if self._browser: + try: + await self._browser.close() + except: + pass + self._browser = None + if self._playwright: + try: + await self._playwright.stop() + except: + pass + self._playwright = None + self._initialized = False + self.page = None + self.controller = None + logger.info("DeepSeek 会话已关闭") + + async def send_message(self, message: str): + if not self.controller: + raise RuntimeError("DeepSeek 会话未启动") + self._cancelled = False + self._last_ai_msg = "" + await self.controller.send(f"[用户任务]\n{message}\n\n请开始执行。") + + async def wait_for_response(self, timeout: int = 0) -> Optional[str]: + if not self.controller: + raise RuntimeError("DeepSeek 会话未启动") + + if timeout <= 0: + timeout = config.task_timeout + + start_time = time.time() + last_msg = self._last_ai_msg + + while time.time() - start_time < timeout: + if self._cancelled: + logger.info("任务已被取消") + return None + + if await self.controller.is_generating(): + await asyncio.sleep(1) + continue + + current_msg = await self.controller.get_latest_ai_message() + if current_msg and current_msg != last_msg: + self._last_ai_msg = current_msg + self._log("RESPONSE", current_msg[:200]) + return current_msg + + await asyncio.sleep(0.5) + + logger.warning(f"等待 DeepSeek 响应超时 ({timeout}s)") + return None + + async def execute_action(self, action_type: str, cmd: str, content: str = "") -> dict: + self._log("EXECUTE", f"[{action_type}] {cmd[:100]}") + result = await self.engine.execute(action_type, cmd, content) + self._log("RESULT", f"success={result['success']}, output={result['output'][:100]}") + return result + + def cancel_current_task(self): + self._cancelled = True + self._log("CANCEL", "任务已取消") + + def get_logs(self, last_n: int = 50) -> str: + if not self._log_buffer: + return "(无日志)" + lines = [f"[{ts}] [{cat}] {msg}" for ts, cat, msg in self._log_buffer[-last_n:]] + return "\n".join(lines) + + def _log(self, category: str, message: str): + ts = time.strftime("%H:%M:%S") + self._log_buffer.append((ts, category, message)) + if len(self._log_buffer) > 500: + self._log_buffer = self._log_buffer[-500:] + logger.debug(f"[{category}] {message}") diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..f8f4e0b --- /dev/null +++ b/start.bat @@ -0,0 +1,4 @@ +@echo off +chcp 65001 >nul 2>&1 +set PYTHONPATH=E:\work\deepseek-web;E:\work\deepseek-web\array_sdk +C:\Miniconda3\python.exe E:\work\deepseek-web\main.py %* diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..8d514c0 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,160 @@ + + + + + +DeepSeek Web 适配器 + + + +
+

DeepSeek Web 适配器

+
+ +平台: -- + +DeepSeek: -- +
+
+
+
+ + + + + +
+
+

📊 系统状态

+
+
平台连接
--
+
DeepSeek 浏览器
--
+
当前任务
--
+
Worker ID
--
+
Worker 名称
--
+
处理状态
--
+
+
+

⚙️ 平台配置

+
+
+
+
+
+
+
+
+
+

🌐 DeepSeek 配置

+
+
+
+
+
+
+

⚡ 执行配置

+
+
+
+
+
+
+
+
+
+ + +
+
+

🔑 注册新 Worker

+

在列阵平台注册一个新的 AI Worker。注册成功后将获得 API Key,请妥善保存。

+
+
+
+
+
+ +
+
+

🌐 DeepSeek 浏览器控制

+

启动 Chrome 浏览器连接 DeepSeek 网页版。首次启动需要手动登录。

+
+ + + +
+

📋 DeepSeek 日志

+
+ + +
+
暂无日志
+
+
+

📋 系统日志

+
+ +
+
暂无日志
+
+
+ + +