feat: DeepSeek Web Adapter - Array platform integration with browser control panel
This commit is contained in:
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
from .config import config, DeepSeekWebConfig
|
||||
from .adapter import DeepSeekWebAdapter
|
||||
from .deepseek_agent_adapter import DeepSeekSession, ADAPTER_SYSTEM_PROMPT
|
||||
+351
@@ -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 "<<<ACTION" in response and "ACTION>>>" in response:
|
||||
logger.warning("ACTION 解析失败,返回错误给 DeepSeek")
|
||||
error_msg = (
|
||||
f"[执行结果]\n错误: ACTION 格式解析失败,请重新输出正确的 ACTION。\n\n"
|
||||
f"本次回复请以 <<<END_OF_MESSAGE>>> 结尾"
|
||||
)
|
||||
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\s*\n?(\{[\s\S]*?\})\s*ACTION>>>'
|
||||
action_match = re.search(action_pattern, text, re.DOTALL)
|
||||
|
||||
if not action_match:
|
||||
if "<<<ACTION" in text and "ACTION>>>" in text:
|
||||
try:
|
||||
start = text.find("<<<ACTION") + len("<<<ACTION")
|
||||
end = text.find("ACTION>>>")
|
||||
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\s*\n?(.*?)\s*CONTENT>>>'
|
||||
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'<<<ACTION\s*[\s\S]*?\s*ACTION>>>', '', text, flags=re.DOTALL)
|
||||
clean = re.sub(r'<<<CONTENT\s*[\s\S]*?\s*CONTENT>>>', '', clean, flags=re.DOTALL)
|
||||
clean = clean.replace("<<<END_OF_MESSAGE>>>", "")
|
||||
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,
|
||||
}
|
||||
@@ -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)
|
||||
+126
@@ -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()
|
||||
@@ -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 <路径> - 写入文件(配合 <<<CONTENT 块使用)
|
||||
• list_dir <路径> - 列出目录内容
|
||||
• 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 块(如需执行命令):
|
||||
<<<ACTION
|
||||
{"type": "命令类型", "cmd": "具体命令", "explanation": "简要说明"}
|
||||
ACTION>>>
|
||||
|
||||
reply 命令格式:
|
||||
<<<ACTION
|
||||
{"type": "reply", "content": "回复内容", "status": "received/progress/question/completed"}
|
||||
ACTION>>>
|
||||
|
||||
2. 结束标记(必须!):
|
||||
<<<END_OF_MESSAGE>>>
|
||||
|
||||
═══════════════════════════════════════════════════════
|
||||
【重要规则】
|
||||
═══════════════════════════════════════════════════════
|
||||
1. 【必须先回复】收到任务后,必须先执行 reply received,再开始工作
|
||||
2. 【必须加结束标记】每次回复的最后一行必须是 <<<END_OF_MESSAGE>>>
|
||||
3. 【非阻塞执行】command/powershell 不等待命令完成,立即返回
|
||||
4. 【必须查询输出】执行命令后,必须用 get_terminal_output 查看结果
|
||||
5. 【交互式程序】如果程序等待输入,用 send_input 发送输入,不要假设程序已结束
|
||||
6. 【文件编码】write_file 配合 <<<CONTENT 块使用,内容不经过 JSON 转义
|
||||
7. 【代码围栏 - 必须!】CONTENT 里的代码必须用 ```python 和 ``` 包裹,否则缩进会丢失
|
||||
8. 【一轮一指令】每次只输出一个 ACTION,等待系统返回后再继续
|
||||
9. 【主动汇报】工作过程中要主动 reply progress 告知用户进度
|
||||
|
||||
═══════════════════════════════════════════════════════
|
||||
【示例 - 完整任务流程】
|
||||
═══════════════════════════════════════════════════════
|
||||
用户:帮我看看桌面上有什么文件
|
||||
|
||||
你(第1轮 - 先回复):
|
||||
<<<ACTION
|
||||
{"type": "reply", "content": "收到,我来看看桌面上有什么文件", "status": "received"}
|
||||
ACTION>>>
|
||||
<<<END_OF_MESSAGE>>>
|
||||
|
||||
你(第2轮 - 执行命令):
|
||||
<<<ACTION
|
||||
{"type": "command", "cmd": "dir ~/Desktop", "explanation": "查看桌面文件"}
|
||||
ACTION>>>
|
||||
<<<END_OF_MESSAGE>>>
|
||||
|
||||
你(第3轮 - 查看输出):
|
||||
<<<ACTION
|
||||
{"type": "get_terminal_output", "cmd": "", "explanation": "查看命令输出"}
|
||||
ACTION>>>
|
||||
<<<END_OF_MESSAGE>>>
|
||||
|
||||
你(第4轮 - 汇报完成):
|
||||
<<<ACTION
|
||||
{"type": "reply", "content": "桌面上有3个文件:report.docx, photo.jpg, notes.txt", "status": "completed"}
|
||||
ACTION>>>
|
||||
<<<END_OF_MESSAGE>>>
|
||||
|
||||
═══════════════════════════════════════════════════════
|
||||
【示例 - 写入文件(必须用代码围栏)】
|
||||
═══════════════════════════════════════════════════════
|
||||
<<<ACTION
|
||||
{"type": "write_file", "path": "test.py", "explanation": "创建测试文件"}
|
||||
ACTION>>>
|
||||
<<<CONTENT
|
||||
```python
|
||||
def hello():
|
||||
print("Hello")
|
||||
return 42
|
||||
```
|
||||
CONTENT>>>
|
||||
<<<END_OF_MESSAGE>>>
|
||||
|
||||
═══════════════════════════════════════════════════════
|
||||
【示例 - 有疑问时】
|
||||
═══════════════════════════════════════════════════════
|
||||
<<<ACTION
|
||||
{"type": "reply", "content": "想确认一下:是要查找所有 .py 文件还是只查找当前目录的?", "status": "question"}
|
||||
ACTION>>>
|
||||
<<<END_OF_MESSAGE>>>
|
||||
"""
|
||||
|
||||
|
||||
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('<<<END_OF_MESSAGE>>>')) {
|
||||
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}")
|
||||
@@ -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 %*
|
||||
@@ -0,0 +1,160 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DeepSeek Web 适配器</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;color:#334155;min-height:100vh}
|
||||
.header{background:linear-gradient(135deg,#3b82f6,#2563eb);padding:16px 24px;display:flex;align-items:center;justify-content:space-between}
|
||||
.header h1{font-size:20px;font-weight:600;color:#fff}
|
||||
.status-bar{display:flex;gap:16px;align-items:center}
|
||||
.status-dot{width:10px;height:10px;border-radius:50%;display:inline-block}
|
||||
.status-dot.online{background:#22c55e;box-shadow:0 0 8px #22c55e80}
|
||||
.status-dot.offline{background:#ef4444;box-shadow:0 0 8px #ef444480}
|
||||
.status-text{font-size:13px;color:#e0e7ff}
|
||||
.container{max-width:1200px;margin:0 auto;padding:24px}
|
||||
.tabs{display:flex;gap:4px;margin-bottom:24px;background:#e2e8f0;border-radius:10px;padding:4px}
|
||||
.tab{padding:10px 20px;border-radius:8px;cursor:pointer;font-size:14px;font-weight:500;color:#64748b;border:none;background:none}
|
||||
.tab:hover{color:#334155;background:#cbd5e1}
|
||||
.tab.active{background:#3b82f6;color:#fff}
|
||||
.panel{display:none}.panel.active{display:block}
|
||||
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
||||
.card h2{font-size:16px;font-weight:600;margin-bottom:16px;color:#1e293b}
|
||||
.form-group{margin-bottom:16px}
|
||||
.form-group label{display:block;font-size:13px;font-weight:500;color:#64748b;margin-bottom:6px}
|
||||
.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px 14px;background:#f8fafc;border:1px solid #cbd5e1;border-radius:8px;color:#334155;font-size:14px;outline:none}
|
||||
.form-group input:focus{border-color:#3b82f6}
|
||||
.form-group input:disabled{opacity:.5}
|
||||
.row{display:flex;gap:16px}.row .form-group{flex:1}
|
||||
.btn{padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;border:none;display:inline-flex;align-items:center;gap:6px}
|
||||
.btn-primary{background:#3b82f6;color:#fff}.btn-primary:hover{background:#2563eb}
|
||||
.btn-danger{background:#ef4444;color:#fff}.btn-danger:hover{background:#dc2626}
|
||||
.btn-success{background:#22c55e;color:#fff}.btn-success:hover{background:#16a34a}
|
||||
.btn-secondary{background:#e2e8f0;color:#475569}.btn-secondary:hover{background:#cbd5e1}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.btn-group{display:flex;gap:8px;margin-top:16px}
|
||||
.log-container{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;max-height:500px;overflow-y:auto;font-family:monospace;font-size:12px;line-height:1.6;color:#475569}
|
||||
.info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}
|
||||
.info-item{background:#f8fafc;border-radius:8px;padding:14px;border:1px solid #e2e8f0}
|
||||
.info-item .label{font-size:12px;color:#94a3b8;margin-bottom:4px}
|
||||
.info-item .value{font-size:16px;font-weight:600;color:#1e293b}
|
||||
.info-item .value.good{color:#16a34a}.info-item .value.bad{color:#dc2626}.info-item .value.warn{color:#d97706}
|
||||
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;font-size:14px;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,.15)}
|
||||
.toast.success{background:#22c55e;color:#fff}.toast.error{background:#ef4444;color:#fff}.toast.info{background:#3b82f6;color:#fff}
|
||||
.register-result{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-top:16px}
|
||||
.register-result .api-key{background:#f1f5f9;padding:8px 12px;border-radius:6px;font-family:monospace;color:#16a34a;margin:8px 0;word-break:break-all;border:1px solid #e2e8f0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>DeepSeek Web 适配器</h1>
|
||||
<div class="status-bar">
|
||||
<span class="status-dot" id="platformDot"></span>
|
||||
<span class="status-text" id="platformStatus">平台: --</span>
|
||||
<span class="status-dot" id="deepseekDot"></span>
|
||||
<span class="status-text" id="deepseekStatus">DeepSeek: --</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('status')">📊 状态</button>
|
||||
<button class="tab" onclick="switchTab('config')">⚙️ 配置</button>
|
||||
<button class="tab" onclick="switchTab('register')">🔑 注册</button>
|
||||
<button class="tab" onclick="switchTab('browser')">🌐 浏览器</button>
|
||||
<button class="tab" onclick="switchTab('logs')">📋 日志</button>
|
||||
</div>
|
||||
<div class="panel active" id="panel-status">
|
||||
<div class="card"><h2>📊 系统状态</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item"><div class="label">平台连接</div><div class="value" id="st-platform">--</div></div>
|
||||
<div class="info-item"><div class="label">DeepSeek 浏览器</div><div class="value" id="st-deepseek">--</div></div>
|
||||
<div class="info-item"><div class="label">当前任务</div><div class="value" id="st-task">--</div></div>
|
||||
<div class="info-item"><div class="label">Worker ID</div><div class="value" id="st-worker-id" style="font-size:12px;word-break:break-all">--</div></div>
|
||||
<div class="info-item"><div class="label">Worker 名称</div><div class="value" id="st-worker-name">--</div></div>
|
||||
<div class="info-item"><div class="label">处理状态</div><div class="value" id="st-processing">--</div></div>
|
||||
</div></div></div>
|
||||
<div class="panel" id="panel-config">
|
||||
<div class="card"><h2>⚙️ 平台配置</h2>
|
||||
<div class="form-group"><label>列阵平台地址</label><input type="text" id="cfg-base-url"></div>
|
||||
<div class="row">
|
||||
<div class="form-group"><label>API Key</label><input type="password" id="cfg-api-key"></div>
|
||||
<div class="form-group"><label>Worker ID</label><input type="text" id="cfg-worker-id" disabled></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group"><label>Worker 名称</label><input type="text" id="cfg-worker-name"></div>
|
||||
<div class="form-group"><label>回复范围</label><select id="cfg-reply-scope"><option value="chat">公开回复</option><option value="private">私密回复</option></select></div>
|
||||
</div></div>
|
||||
<div class="card"><h2>🌐 DeepSeek 配置</h2>
|
||||
<div class="row">
|
||||
<div class="form-group"><label>DeepSeek 网址</label><input type="text" id="cfg-deepseek-url"></div>
|
||||
<div class="form-group"><label>无头模式</label><select id="cfg-headless"><option value="false">否</option><option value="true">是</option></select></div>
|
||||
</div>
|
||||
<div class="form-group"><label>用户数据目录</label><input type="text" id="cfg-user-data-dir"></div>
|
||||
</div>
|
||||
<div class="card"><h2>⚡ 执行配置</h2>
|
||||
<div class="row">
|
||||
<div class="form-group"><label>最大交互轮数</label><input type="number" id="cfg-max-rounds"></div>
|
||||
<div class="form-group"><label>任务超时(秒)</label><input type="number" id="cfg-task-timeout"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group"><label>控制面板端口</label><input type="number" id="cfg-admin-port"></div>
|
||||
<div class="form-group"><label>日志级别</label><select id="cfg-log-level"><option value="DEBUG">DEBUG</option><option value="INFO">INFO</option><option value="WARNING">WARNING</option><option value="ERROR">ERROR</option></select></div>
|
||||
</div></div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="saveConfig()">💾 保存配置</button>
|
||||
<button class="btn btn-secondary" onclick="loadConfig()">🔄 重新加载</button>
|
||||
</div></div>
|
||||
<div class="panel" id="panel-register">
|
||||
<div class="card"><h2>🔑 注册新 Worker</h2>
|
||||
<p style="color:#94a3b8;margin-bottom:16px;font-size:14px">在列阵平台注册一个新的 AI Worker。注册成功后将获得 API Key,请妥善保存。</p>
|
||||
<div class="form-group"><label>Worker 名称</label><input type="text" id="reg-name" value="DeepSeek-Web"></div>
|
||||
<div class="form-group"><label>Worker 类型</label><select id="reg-type"><option value="ai" selected>AI</option><option value="tool">工具</option></select></div>
|
||||
<div class="form-group"><label>描述(可选)</label><textarea id="reg-description" rows="3"></textarea></div>
|
||||
<div class="form-group"><label>邀请码(可选)</label><input type="text" id="reg-invite-code"></div>
|
||||
<div class="btn-group"><button class="btn btn-success" onclick="registerWorker()">🚀 注册</button></div>
|
||||
<div id="registerResult" style="display:none"></div>
|
||||
</div></div>
|
||||
<div class="panel" id="panel-browser">
|
||||
<div class="card"><h2>🌐 DeepSeek 浏览器控制</h2>
|
||||
<p style="color:#94a3b8;margin-bottom:16px;font-size:14px">启动 Chrome 浏览器连接 DeepSeek 网页版。首次启动需要手动登录。</p>
|
||||
<div class="btn-group" style="margin-top:0">
|
||||
<button class="btn btn-success" id="btn-start" onclick="startBrowser()">🚀 启动浏览器</button>
|
||||
<button class="btn btn-danger" id="btn-stop" onclick="stopBrowser()" disabled>⏹️ 关闭浏览器</button>
|
||||
<button class="btn btn-secondary" onclick="reinitPrompt()">🔄 重新发送系统提示词</button>
|
||||
</div></div>
|
||||
<div class="card"><h2>📋 DeepSeek 日志</h2>
|
||||
<div class="btn-group" style="margin-top:0;margin-bottom:12px">
|
||||
<button class="btn btn-secondary" onclick="refreshLogs()">🔄 刷新</button>
|
||||
<button class="btn btn-secondary" onclick="clearLogs()">🗑️ 清空</button>
|
||||
</div>
|
||||
<div class="log-container" id="deepseekLogs"><div style="color:#64748b">暂无日志</div></div>
|
||||
</div></div>
|
||||
<div class="panel" id="panel-logs">
|
||||
<div class="card"><h2>📋 系统日志</h2>
|
||||
<div class="btn-group" style="margin-top:0;margin-bottom:12px">
|
||||
<button class="btn btn-secondary" onclick="refreshSystemLogs()">🔄 刷新</button>
|
||||
</div>
|
||||
<div class="log-container" id="systemLogs"><div style="color:#64748b">暂无日志</div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
<script>
|
||||
function switchTab(n){document.querySelectorAll('.tab').forEach(function(t){t.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(p){p.classList.remove('active')});event.target.classList.add('active');document.getElementById('panel-'+n).classList.add('active')}
|
||||
function showToast(m,t){t=t||'info';var e=document.createElement('div');e.className='toast '+t;e.textContent=m;document.body.appendChild(e);setTimeout(function(){e.remove()},3000)}
|
||||
function api(p,m,d){m=m||'GET';d=d||null;var o={method:m,headers:{'Content-Type':'application/json'}};if(d)o.body=JSON.stringify(d);return fetch('/api'+p,o).then(function(r){return r.json()})}
|
||||
function loadConfig(){api('/config').then(function(c){if(c.error){showToast(c.error,'error');return}document.getElementById('cfg-base-url').value=c.array_base_url||'';document.getElementById('cfg-api-key').value=c.api_key||'';document.getElementById('cfg-worker-id').value=c.worker_id||'';document.getElementById('cfg-worker-name').value=c.worker_name||'';document.getElementById('cfg-reply-scope').value=c.reply_scope||'chat';document.getElementById('cfg-deepseek-url').value=c.deepseek_url||'';document.getElementById('cfg-headless').value=String(c.headless||false);document.getElementById('cfg-user-data-dir').value=c.user_data_dir||'';document.getElementById('cfg-max-rounds').value=c.max_rounds||30;document.getElementById('cfg-task-timeout').value=c.task_timeout||600;document.getElementById('cfg-admin-port').value=c.admin_port||9091;document.getElementById('cfg-log-level').value=c.log_level||'INFO'})}
|
||||
function saveConfig(){var d={array_base_url:document.getElementById('cfg-base-url').value,api_key:document.getElementById('cfg-api-key').value,worker_name:document.getElementById('cfg-worker-name').value,reply_scope:document.getElementById('cfg-reply-scope').value,deepseek_url:document.getElementById('cfg-deepseek-url').value,headless:document.getElementById('cfg-headless').value==='true',user_data_dir:document.getElementById('cfg-user-data-dir').value,max_rounds:parseInt(document.getElementById('cfg-max-rounds').value)||30,task_timeout:parseInt(document.getElementById('cfg-task-timeout').value)||600,admin_port:parseInt(document.getElementById('cfg-admin-port').value)||9091,log_level:document.getElementById('cfg-log-level').value};api('/config','POST',d).then(function(r){if(r.success)showToast('配置已保存','success');else showToast(r.error||'保存失败','error')})}
|
||||
function registerWorker(){var d={name:document.getElementById('reg-name').value,type:document.getElementById('reg-type').value,description:document.getElementById('reg-description').value,invite_code:document.getElementById('reg-invite-code').value};api('/register','POST',d).then(function(r){var el=document.getElementById('registerResult');el.style.display='block';if(r.error){el.innerHTML='<div class="register-result"><p style="color:#ef4444">注册失败: '+r.error+'</p></div>'}else{var kid=r.worker_id||'';var kkey=r.api_key||'';el.innerHTML='<div class="register-result"><p style="color:#22c55e">注册成功!</p><p>Worker ID:</p><div class="api-key">'+kid+'</div><p>API Key(仅显示一次,请立即保存!):</p><div class="api-key">'+kkey+'</div><div class="btn-group"><button class="btn btn-primary" onclick="fillApiKey(\''+kkey.replace(/'/g,"\\'")+'\')">填入配置</button></div></div>'}})}
|
||||
function fillApiKey(k){document.getElementById('cfg-api-key').value=k;switchTab('config');document.querySelectorAll('.tab').forEach(function(t){t.classList.remove('active')});document.querySelectorAll('.tab')[1].classList.add('active');showToast('API Key 已填入','info')}
|
||||
function startBrowser(){document.getElementById('btn-start').disabled=true;api('/browser/start','POST').then(function(r){if(r.success)showToast('浏览器启动中...','success');else{showToast(r.error||'启动失败','error');document.getElementById('btn-start').disabled=false}setTimeout(refreshStatus,3000)})}
|
||||
function stopBrowser(){document.getElementById('btn-stop').disabled=true;api('/browser/stop','POST').then(function(r){if(r.success)showToast('浏览器已关闭','success');else showToast(r.error||'关闭失败','error');setTimeout(refreshStatus,2000)})}
|
||||
function reinitPrompt(){api('/browser/reinit','POST').then(function(r){if(r.success)showToast('系统提示词已重新发送','success');else showToast(r.error||'操作失败','error')})}
|
||||
function refreshStatus(){api('/status').then(function(s){var pD=document.getElementById('platformDot'),pT=document.getElementById('platformStatus'),dD=document.getElementById('deepseekDot'),dT=document.getElementById('deepseekStatus');if(s.connected){pD.className='status-dot online';pT.textContent='平台: 已连接'}else{pD.className='status-dot offline';pT.textContent='平台: 未连接'}if(s.deepseek_ready){dD.className='status-dot online';dT.textContent='DeepSeek: 就绪'}else{dD.className='status-dot offline';dT.textContent='DeepSeek: 未启动'}document.getElementById('st-platform').textContent=s.connected?'已连接':'未连接';document.getElementById('st-platform').className='value '+(s.connected?'good':'bad');document.getElementById('st-deepseek').textContent=s.deepseek_ready?'就绪':'未启动';document.getElementById('st-deepseek').className='value '+(s.deepseek_ready?'good':'bad');document.getElementById('st-task').textContent=s.current_task||'空闲';document.getElementById('st-task').className='value '+(s.current_task?'warn':'good');document.getElementById('st-worker-id').textContent=s.worker_id||'--';document.getElementById('st-worker-name').textContent=s.worker_name||'--';document.getElementById('st-processing').textContent=s.processing?'处理中':'空闲';document.getElementById('st-processing').className='value '+(s.processing?'warn':'good');document.getElementById('btn-start').disabled=s.deepseek_ready;document.getElementById('btn-stop').disabled=!s.deepseek_ready})}
|
||||
function refreshLogs(){api('/logs/deepseek').then(function(r){var el=document.getElementById('deepseekLogs');if(r.logs){el.innerHTML=r.logs.split('\n').map(function(l){return '<div>'+l.replace(/</g,'<')+'</div>'}).join('');el.scrollTop=el.scrollHeight}else{el.innerHTML='<div style="color:#64748b">暂无日志</div>'}})}
|
||||
function clearLogs(){api('/logs/deepseek','DELETE').then(function(){refreshLogs()})}
|
||||
function refreshSystemLogs(){api('/logs/system').then(function(r){var el=document.getElementById('systemLogs');if(r.logs){el.innerHTML=r.logs.split('\n').map(function(l){return '<div>'+l.replace(/</g,'<')+'</div>'}).join('');el.scrollTop=el.scrollHeight}else{el.innerHTML='<div style="color:#64748b">暂无日志</div>'}})}
|
||||
loadConfig();refreshStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user