opencode_client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. # -*- coding: utf-8 -*-
  2. """
  3. opencode_client.py — 通过本机常驻 opencode 服务的 HTTP API 进行会话交互
  4. 负责:
  5. 1. 认证(basic auth)
  6. 2. chat_id → session_id 映射的持久化(JSON 文件,每用户/群一个 opencode 会话)
  7. 3. 确保某 chat 的会话存在(build agent)
  8. 4. 发消息 + 轮询取 assistant 最终文本回复
  9. 权限策略:
  10. - 允许 bash/shell(可执行 git 等只读命令,满足「检查同步」类需求)
  11. - 仍禁用 write/edit/apply_patch 等写文件操作与任务派发
  12. - 因此 opencode 可以分析、执行只读命令、生成回复,但无法改动本机文件
  13. """
  14. import io
  15. import json
  16. import os
  17. import sys
  18. import time
  19. import urllib.parse
  20. import urllib.request
  21. import base64
  22. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  23. OPERATION_DIR = SCRIPT_DIR
  24. CFC_ROOT = os.path.dirname(OPERATION_DIR)
  25. # opencode 常驻服务
  26. OPCODE_BASE = "http://127.0.0.1:4090"
  27. OPCODE_USER = "opencode"
  28. OPCODE_PASSWORD = os.environ.get("OPENCODE_SERVER_PASSWORD", "IwinTrue@123")
  29. # 会话映射文件(chat_id -> session_id)
  30. SESSION_MAP_PATH = os.path.join(OPERATION_DIR, "opencode_sessions.json")
  31. # opencode 会话工作目录(项目根,含素材库/脚本,open code 可直接只读分析)
  32. WORK_DIR = CFC_ROOT
  33. # build agent 默认工作于该 project 的模型取服务端默认
  34. AGENT = "build"
  35. # 默认模型。opencode 内置 big-pickle 免费额度有限流,显式指定可用的 axon/deepseek。
  36. MODEL = {"providerID": "axon", "modelID": "deepseek"}
  37. # 只读工具白名单:其余工具(bash 执行、写文件等)一律禁用
  38. # 此处列出 opencode 内置常用只读工具;未列出的默认不可用(服务端按 tools 参数过滤)
  39. READONLY_TOOLS = []
  40. # 危险的需授权工具,显式禁用(防御性,即便服务端宽松也不给)
  41. FORBIDDEN_TOOLS = [
  42. "write", "edit", "apply_patch",
  43. "task", "dispatch", "webfetch_post", "chrome_launch",
  44. ]
  45. def _auth_header() -> str:
  46. token = base64.b64encode(
  47. f"{OPCODE_USER}:{OPCODE_PASSWORD}".encode("utf-8")
  48. ).decode("ascii")
  49. return f"Basic {token}"
  50. def _api(method: str, path: str, body=None, timeout: float = 60):
  51. """请求 opencode HTTP API,返回 (status, decoded_body)。"""
  52. url = OPCODE_BASE + path
  53. data = None
  54. if body is not None:
  55. data = json.dumps(body, ensure_ascii=False).encode("utf-8")
  56. req = urllib.request.Request(url, data=data, method=method)
  57. req.add_header("Authorization", _auth_header())
  58. if body is not None:
  59. req.add_header("Content-Type", "application/json; charset=utf-8")
  60. with urllib.request.urlopen(req, timeout=timeout) as resp:
  61. raw = resp.read().decode("utf-8", errors="replace")
  62. return resp.status, raw
  63. def _load_session_map() -> dict:
  64. if not os.path.exists(SESSION_MAP_PATH):
  65. return {}
  66. try:
  67. with open(SESSION_MAP_PATH, "r", encoding="utf-8") as f:
  68. return json.load(f)
  69. except Exception:
  70. return {}
  71. def _save_session_map(mapping: dict):
  72. with open(SESSION_MAP_PATH, "w", encoding="utf-8") as f:
  73. json.dump(mapping, f, ensure_ascii=False, indent=2)
  74. def _create_session(chat_id: str) -> str:
  75. """为某 chat 新建一个 build agent 会话,返回 session_id。"""
  76. dir_q = urllib.parse.quote(WORK_DIR)
  77. body = {
  78. "title": f"xhs-bot-{chat_id[:24]}",
  79. "agent": AGENT,
  80. }
  81. st, raw = _api("POST", f"/session?directory={dir_q}", body=body, timeout=30)
  82. if st not in (200, 201):
  83. raise RuntimeError(f"创建会话失败 status={st}: {raw[:300]}")
  84. data = json.loads(raw)
  85. sid = data.get("id")
  86. if not sid:
  87. raise RuntimeError(f"创建会话未返回 session_id: {raw[:300]}")
  88. return sid
  89. def ensure_session(chat_id: str) -> str:
  90. """确保某 chat 有对应 opencode 会话,返回 session_id。"""
  91. mapping = _load_session_map()
  92. sid = mapping.get(chat_id)
  93. if sid:
  94. return sid
  95. sid = _create_session(chat_id)
  96. mapping[chat_id] = sid
  97. _save_session_map(mapping)
  98. return sid
  99. def send_prompt(session_id: str, text: str, timeout: float = 30):
  100. """向指定会话异步发送一条用户消息。返回 HTTP 状态。"""
  101. parts = [{"type": "text", "text": text}]
  102. body = {
  103. "parts": parts,
  104. "model": MODEL,
  105. "tools": {t: False for t in FORBIDDEN_TOOLS},
  106. }
  107. st, raw = _api("POST", f"/session/{session_id}/prompt_async",
  108. body=body, timeout=timeout)
  109. return st, raw
  110. def _extract_assistant_text(msgs) -> str:
  111. """从 v1 message 列表里提取最后一条 assistant 的最终可见文本。"""
  112. last = msgs[-1] if msgs else None
  113. if not last:
  114. return ""
  115. if last.get("info", {}).get("role") != "assistant":
  116. return ""
  117. texts = [p.get("text", "") for p in last.get("parts", [])
  118. if p.get("type") == "text" and p.get("text")]
  119. return texts[-1] if texts else ""
  120. def _extract_progress(msgs, sent_ts: float = 0.0):
  121. """从消息列表提取当前进展摘要(含思考 reasoning、工具调用、输出文本)。
  122. sent_ts:仅提取该时刻之后(本次 prompt 之后)新增的 assistant 消息,
  123. 防止复用会话时把旧回复当成新进展上报。
  124. """
  125. items = []
  126. for m in reversed(msgs):
  127. info = m.get("info", {})
  128. if info.get("role") != "assistant":
  129. continue
  130. m_created = (info.get("time") or {}).get("created", 0)
  131. if m_created < sent_ts:
  132. continue
  133. for p in reversed(m.get("parts", [])):
  134. t = p.get("type")
  135. if t == "reasoning":
  136. txt = (p.get("text") or "").strip()
  137. if txt:
  138. items.append(("思考", txt))
  139. elif t == "text":
  140. txt = (p.get("text") or "").strip()
  141. if txt:
  142. items.append(("输出", txt))
  143. elif t == "tool":
  144. tool = p.get("tool") or {}
  145. name = tool.get("name") if isinstance(tool, dict) else str(tool)
  146. if name:
  147. items.append(("工具", str(name)))
  148. if len(items) >= 3:
  149. break
  150. if len(items) >= 3:
  151. break
  152. if not items:
  153. return "(处理中...)"
  154. return " | ".join(f"[{k}] {v[:120]}" for k, v in items)
  155. def wait_for_reply(session_id: str, timeout: float = 180,
  156. poll_interval: float = 5.0,
  157. progress_callback=None, progress_interval: float = 120.0,
  158. sent_ts: float = None) -> str:
  159. """等待会话最新 assistant 回复完成,返回最终文本。
  160. 处理两种情形:
  161. 1) assistant 回复极快,进入本函数时已生成 → 直接看最后一条 assistant 文本
  162. 2) 正常流式:等待「发消息后新增」的 assistant 带文本出现
  163. 超长任务支持(progress_callback 非 None 时启用):
  164. - 每 progress_interval 秒提取进展(含 reasoning)回调给调用方
  165. - 若两次回调间无任何进展,向 opencode 提交「在干什么」推动执行
  166. sent_ts:发送 prompt 时的毫秒时间戳。所有返回路径只接受此时刻之后的
  167. assistant 消息,防止在复用会话中错误返回旧消息。
  168. timeout 为 None 时表示无限等待,直到 assistant 产生最终回复才返回。
  169. """
  170. _sent_ts = sent_ts or 0.0
  171. try:
  172. _, raw0 = _api("GET", f"/session/{session_id}/message", timeout=30)
  173. before = len(json.loads(raw0)) if raw0 else 0
  174. except Exception:
  175. before = 0
  176. deadline = (time.time() + timeout) if timeout is not None else None
  177. last_report_at = time.time()
  178. # 跟踪最后一条 assistant 消息的 created 时间戳,只有它不更新才算真卡住
  179. last_asst_ts = 0
  180. # nudge 冷却:同一会话 300s 内只允许触发一次
  181. last_nudge_at = 0
  182. while deadline is None or time.time() < deadline:
  183. time.sleep(poll_interval)
  184. try:
  185. st, raw = _api("GET", f"/session/{session_id}/message", timeout=30)
  186. if st != 200 or not raw:
  187. continue
  188. msgs = json.loads(raw)
  189. except Exception:
  190. continue
  191. if not msgs:
  192. continue
  193. # 快速路径:最后一条消息是新 assistant 且有文本 → 直接返回
  194. last = msgs[-1]
  195. if last.get("info", {}).get("role") == "assistant":
  196. last_created = (last.get("info", {}).get("time") or {}).get("created", 0)
  197. if last_created >= _sent_ts:
  198. lt = [p.get("text", "") for p in last.get("parts", [])
  199. if p.get("type") == "text" and (p.get("text") or "").strip()]
  200. if lt:
  201. return lt[-1]
  202. # 常规路径:遍历消息找第一个(最新)满足条件的 assistant 文本
  203. if len(msgs) > before:
  204. for m in reversed(msgs):
  205. info = m.get("info", {})
  206. if info.get("role") != "assistant":
  207. continue
  208. m_created = (info.get("time") or {}).get("created", 0)
  209. if m_created < _sent_ts:
  210. continue
  211. txts = [p.get("text", "") for p in m.get("parts", [])
  212. if p.get("type") == "text" and (p.get("text") or "").strip()]
  213. if txts:
  214. return txts[-1]
  215. # 超长任务进展上报(含卡住检测)
  216. if progress_callback and (time.time() - last_report_at) >= progress_interval:
  217. last_report_at = time.time()
  218. progress = _extract_progress(msgs, sent_ts=_sent_ts)
  219. # 找到最新的 assistant 消息的 created 时间戳
  220. latest_asst_ts = 0
  221. for m in msgs:
  222. info = m.get("info", {})
  223. if info.get("role") == "assistant":
  224. ts = (info.get("time") or {}).get("created", 0)
  225. if ts > latest_asst_ts:
  226. latest_asst_ts = ts
  227. if latest_asst_ts == last_asst_ts and latest_asst_ts > _sent_ts:
  228. # 真正的卡住:assistant 消息时间戳没有变化
  229. now = time.time()
  230. if now - last_nudge_at >= 300:
  231. last_nudge_at = now
  232. progress_callback("⏳ 2 分钟无新进展,已向 opencode 询问「在干什么」…")
  233. try:
  234. send_prompt(session_id, "在干什么")
  235. except Exception:
  236. pass
  237. else:
  238. last_asst_ts = latest_asst_ts
  239. progress_callback(f"⏳ 进展:{progress}")
  240. raise TimeoutError(f"等待 opencode 回复超时({timeout}s)")
  241. def ask(chat_id: str, text: str, timeout: float = None,
  242. progress_callback=None, progress_interval: float = 120.0) -> str:
  243. """高层封装:确保会话 → 发消息 → 等回复 → 返回最终文本。
  244. timeout 为 None(默认)时无限等待,直到任务结束。
  245. """
  246. sid = ensure_session(chat_id)
  247. # 记录发送时刻(先于发送,保证新消息 created >= sent_ts,旧消息被过滤)
  248. sent_ts = time.time() * 1000
  249. st, raw = send_prompt(sid, text)
  250. if st != 204:
  251. raise RuntimeError(f"发送到 opencode 失败 status={st}: {raw[:300]}")
  252. return wait_for_reply(sid, timeout=timeout,
  253. progress_callback=progress_callback,
  254. progress_interval=progress_interval,
  255. sent_ts=sent_ts)
  256. if __name__ == "__main__":
  257. if not sys.stdout.isatty():
  258. try:
  259. sys.stdout.reconfigure(encoding='utf-8', errors='replace')
  260. except Exception:
  261. pass
  262. test_chat = "test-opencode-client"
  263. reply = ask(test_chat, "请用一句话回复:收到消息了吗?")
  264. print(f"REPLY: {reply}")