Files
ai-video-fullstack/backend/services/background_tasks/opencode.py
T

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, ensure_ascii=False)}
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}
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 = []