feat: DeepSeek Web Adapter - Array platform integration with browser control panel

This commit is contained in:
DeepSeek-Web Adapter
2026-04-17 16:11:31 +08:00
parent 9a349dae8b
commit a6820b2cbb
12 changed files with 2527 additions and 0 deletions
+6
View File
@@ -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"]
+186
View File
@@ -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)
+641
View File
@@ -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)
+131
View File
@@ -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)
+69
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
from .config import config, DeepSeekWebConfig
from .adapter import DeepSeekWebAdapter
from .deepseek_agent_adapter import DeepSeekSession, ADAPTER_SYSTEM_PROMPT
+351
View File
@@ -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,
}
+194
View File
@@ -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
View File
@@ -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()
+656
View File
@@ -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}")
+4
View File
@@ -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 %*
+160
View File
@@ -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,'&lt;')+'</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,'&lt;')+'</div>'}).join('');el.scrollTop=el.scrollHeight}else{el.innerHTML='<div style="color:#64748b">暂无日志</div>'}})}
loadConfig();refreshStatus();
</script>
</body>
</html>