F1: add correlated OpenCode HTTP and SSE task adapter
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""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 = []
|
||||
Reference in New Issue
Block a user