147 lines
7.6 KiB
Python
147 lines
7.6 KiB
Python
"""OpenCode HTTP/SSE adapter. No Pipecat, database or browser dependencies."""
|
|
|
|
import json
|
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
|
|
Emit = Callable[[dict], Awaitable[None]]
|
|
|
|
|
|
class RemoteTaskError(RuntimeError):
|
|
"""A remote task failed, without including connection credentials."""
|
|
|
|
|
|
class OpenCodeExecutor:
|
|
def __init__(self, config: dict, secrets: dict, *, transport=None):
|
|
self.config = config
|
|
self._secrets = secrets
|
|
self._transport = transport
|
|
|
|
def _client(self):
|
|
password = str(self._secrets.get("password") or "")
|
|
return httpx.AsyncClient(
|
|
base_url=self.config["url"].rstrip("/") + "/",
|
|
params={"directory": self.config["directory"]},
|
|
auth=httpx.BasicAuth(self.config.get("username", "opencode"), password) if password else None,
|
|
timeout=httpx.Timeout(20, read=45),
|
|
follow_redirects=False,
|
|
transport=self._transport,
|
|
)
|
|
|
|
async def prepare(self) -> dict:
|
|
async with self._client() as client:
|
|
response = await client.post("session", json={"title": "语音助手后台任务"})
|
|
response.raise_for_status()
|
|
session_id = response.json()["id"]
|
|
# Stored before prompt submission, so reconnects never create a second run.
|
|
return {"session_id": session_id, "message_id": "msg_" + uuid4().hex}
|
|
|
|
@staticmethod
|
|
async def _snapshot(client, handle) -> dict:
|
|
response = await client.get(f"session/{handle['session_id']}/message")
|
|
response.raise_for_status()
|
|
messages = response.json()
|
|
status = await client.get("session/status")
|
|
status.raise_for_status()
|
|
remote_status = status.json().get(handle["session_id"], {"type": "idle"})
|
|
matched = [item for item in messages if
|
|
item.get("info", {}).get("role") == "assistant" and
|
|
item.get("info", {}).get("parentID") == handle["message_id"]]
|
|
errors = [item["info"]["error"] for item in matched if item["info"].get("error")]
|
|
if errors:
|
|
return {"status": "failed", "summary": "远端执行失败", "result": json.dumps(errors[-1], ensure_ascii=False)[:8000]}
|
|
completed = [item for item in matched if item["info"].get("time", {}).get("completed")]
|
|
if remote_status.get("type") == "idle" and completed:
|
|
text = "\n".join(part.get("text", "") for item in completed for part in item.get("parts", []) if part.get("type") == "text")
|
|
return {"status": "succeeded", "summary": "任务已完成", "result": text[-16000:]}
|
|
return {"status": "running", "summary": "远端任务执行中", "result": ""}
|
|
|
|
async def snapshot(self, handle: dict) -> dict:
|
|
async with self._client() as client:
|
|
return await self._snapshot(client, handle)
|
|
|
|
async def run(self, handle: dict, instruction: str, emit: Emit, *, resume=False) -> dict:
|
|
"""Subscribe before submitting; only correlated completed messages finish a run.
|
|
|
|
Reconnect/restart never resubmits a prompt. The host calls this with
|
|
resume=True after a lost stream and reconciles from the messages API.
|
|
"""
|
|
async with self._client() as client:
|
|
if resume:
|
|
state = await self._snapshot(client, handle)
|
|
if state["status"] in {"succeeded", "failed"}:
|
|
return state
|
|
async with client.stream("GET", "event") as stream:
|
|
stream.raise_for_status()
|
|
if not resume:
|
|
response = await client.post(
|
|
f"session/{handle['session_id']}/prompt_async",
|
|
json={"messageID": handle["message_id"], "parts": [{"type": "text", "text": instruction}]},
|
|
)
|
|
response.raise_for_status()
|
|
await emit({"status": "submitted", "summary": "远端已接收,等待执行"})
|
|
async for event in self._events(stream):
|
|
props = event.get("properties") or {}
|
|
info = props.get("info") or {}
|
|
part = props.get("part") or {}
|
|
session_id = props.get("sessionID") or info.get("sessionID") or part.get("sessionID")
|
|
if session_id != handle["session_id"]:
|
|
continue
|
|
kind = event.get("type")
|
|
if kind == "session.error":
|
|
return {"status": "failed", "summary": "远端执行失败", "result": "请检查 OpenCode 会话中的错误信息。"}
|
|
if kind in {"permission.asked", "question.asked"}:
|
|
request = {key: props[key] for key in ("id", "permission", "patterns", "questions") if key in props}
|
|
request["kind"] = "permission" if kind.startswith("permission") else "question"
|
|
await emit({"status": "waiting_input", "summary": "远端等待确认或补充信息", "input_request": request})
|
|
elif kind == "message.part.updated" and part.get("type") == "tool":
|
|
state = part.get("state") or {}
|
|
await emit({"status": "running", "summary": f"{part.get('tool', '工具')} · {state.get('status', '执行中')}"[:300], "input_request": {}})
|
|
elif kind == "session.status" and (props.get("status") or {}).get("type") == "busy":
|
|
await emit({"status": "running", "summary": "远端任务执行中"})
|
|
if kind in {"session.idle", "session.status", "message.updated"}:
|
|
state = await self._snapshot(client, handle)
|
|
if state["status"] in {"succeeded", "failed"}:
|
|
return state
|
|
raise RemoteTaskError("远端事件流已关闭,需要重新连接")
|
|
|
|
async def cancel(self, handle: dict) -> bool:
|
|
async with self._client() as client:
|
|
response = await client.post(f"session/{handle['session_id']}/abort")
|
|
response.raise_for_status()
|
|
status = await client.get("session/status")
|
|
status.raise_for_status()
|
|
value = status.json().get(handle["session_id"], {"type": "idle"})
|
|
return value.get("type") == "idle"
|
|
|
|
async def send_input(self, request: dict, value: dict):
|
|
async with self._client() as client:
|
|
if request.get("kind") == "permission":
|
|
reply = value.get("reply")
|
|
if reply not in {"once", "reject"}:
|
|
raise ValueError("请选择允许本次或拒绝")
|
|
response = await client.post(f"permission/{request['id']}/reply", json={"reply": reply})
|
|
elif request.get("kind") == "question":
|
|
answers = value.get("answers")
|
|
if not isinstance(answers, list) or len(answers) != len(request.get("questions") or []):
|
|
raise ValueError("请回答远端提出的每一个问题")
|
|
response = await client.post(f"question/{request['id']}/reply", json={"answers": answers})
|
|
else:
|
|
raise ValueError("当前任务没有待回答的问题")
|
|
response.raise_for_status()
|
|
|
|
@staticmethod
|
|
async def _events(stream) -> AsyncIterator[dict]:
|
|
data = []
|
|
async for line in stream.aiter_lines():
|
|
if line.startswith("data:"):
|
|
data.append(line[5:].lstrip())
|
|
elif not line and data:
|
|
try:
|
|
yield json.loads("\n".join(data))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
data = []
|