opencode_client.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  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. - 只为会话启用「只读」类工具(读文件、浏览、文本生成)
  11. - 禁用运行命令 / 写文件等需要授权的操作
  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. # 只读工具白名单:其余工具(bash 执行、写文件等)一律禁用
  36. # 此处列出 opencode 内置常用只读工具;未列出的默认不可用(服务端按 tools 参数过滤)
  37. READONLY_TOOLS = []
  38. # 危险的需授权工具,显式禁用(防御性,即便服务端宽松也不给)
  39. FORBIDDEN_TOOLS = [
  40. "bash", "shell", "write", "edit", "apply_patch",
  41. "task", "dispatch", "webfetch_post", "chrome_launch",
  42. ]
  43. def _auth_header() -> str:
  44. token = base64.b64encode(
  45. f"{OPCODE_USER}:{OPCODE_PASSWORD}".encode("utf-8")
  46. ).decode("ascii")
  47. return f"Basic {token}"
  48. def _api(method: str, path: str, body=None, timeout: float = 60):
  49. """请求 opencode HTTP API,返回 (status, decoded_body)。"""
  50. url = OPCODE_BASE + path
  51. data = None
  52. if body is not None:
  53. data = json.dumps(body, ensure_ascii=False).encode("utf-8")
  54. req = urllib.request.Request(url, data=data, method=method)
  55. req.add_header("Authorization", _auth_header())
  56. if body is not None:
  57. req.add_header("Content-Type", "application/json; charset=utf-8")
  58. with urllib.request.urlopen(req, timeout=timeout) as resp:
  59. raw = resp.read().decode("utf-8", errors="replace")
  60. return resp.status, raw
  61. def _load_session_map() -> dict:
  62. if not os.path.exists(SESSION_MAP_PATH):
  63. return {}
  64. try:
  65. with open(SESSION_MAP_PATH, "r", encoding="utf-8") as f:
  66. return json.load(f)
  67. except Exception:
  68. return {}
  69. def _save_session_map(mapping: dict):
  70. with open(SESSION_MAP_PATH, "w", encoding="utf-8") as f:
  71. json.dump(mapping, f, ensure_ascii=False, indent=2)
  72. def _create_session(chat_id: str) -> str:
  73. """为某 chat 新建一个 build agent 会话,返回 session_id。"""
  74. dir_q = urllib.parse.quote(WORK_DIR)
  75. body = {
  76. "title": f"xhs-bot-{chat_id[:24]}",
  77. "agent": AGENT,
  78. }
  79. st, raw = _api("POST", f"/session?directory={dir_q}", body=body, timeout=30)
  80. if st not in (200, 201):
  81. raise RuntimeError(f"创建会话失败 status={st}: {raw[:300]}")
  82. data = json.loads(raw)
  83. sid = data.get("id")
  84. if not sid:
  85. raise RuntimeError(f"创建会话未返回 session_id: {raw[:300]}")
  86. return sid
  87. def ensure_session(chat_id: str) -> str:
  88. """确保某 chat 有对应 opencode 会话,返回 session_id。"""
  89. mapping = _load_session_map()
  90. sid = mapping.get(chat_id)
  91. if sid:
  92. return sid
  93. sid = _create_session(chat_id)
  94. mapping[chat_id] = sid
  95. _save_session_map(mapping)
  96. return sid
  97. def send_prompt(session_id: str, text: str, timeout: float = 30):
  98. """向指定会话异步发送一条用户消息。返回 HTTP 状态。"""
  99. parts = [{"type": "text", "text": text}]
  100. body = {
  101. "parts": parts,
  102. # 只读约束:明确禁用需授权的执行/写入类工具
  103. "tools": {t: False for t in FORBIDDEN_TOOLS},
  104. }
  105. st, raw = _api("POST", f"/session/{session_id}/prompt_async",
  106. body=body, timeout=timeout)
  107. return st, raw
  108. def _extract_assistant_text(msgs) -> str:
  109. """从 v1 message 列表里提取最后一条 assistant 的最终可见文本。"""
  110. last = msgs[-1] if msgs else None
  111. if not last:
  112. return ""
  113. if last.get("info", {}).get("role") != "assistant":
  114. return ""
  115. texts = [p.get("text", "") for p in last.get("parts", [])
  116. if p.get("type") == "text" and p.get("text")]
  117. return texts[-1] if texts else ""
  118. def wait_for_reply(session_id: str, timeout: float = 180,
  119. poll_interval: float = 5.0) -> str:
  120. """等待会话最新 assistant 回复完成,返回最终文本。
  121. 处理两种情形:
  122. 1) assistant 回复极快,进入本函数时已生成 → 直接看最后一条 assistant 文本
  123. 2) 正常流式:等待「发消息后新增」的 assistant 带文本出现
  124. """
  125. # 进入时先记录当前消息条数,作为"已有"基线
  126. try:
  127. _, raw0 = _api("GET", f"/session/{session_id}/message", timeout=30)
  128. before = len(json.loads(raw0)) if raw0 else 0
  129. except Exception:
  130. before = 0
  131. deadline = time.time() + timeout
  132. while time.time() < deadline:
  133. time.sleep(poll_interval)
  134. try:
  135. st, raw = _api("GET", f"/session/{session_id}/message", timeout=30)
  136. if st != 200 or not raw:
  137. continue
  138. msgs = json.loads(raw)
  139. except Exception:
  140. continue
  141. if not msgs:
  142. continue
  143. # 情况1:最后一条是 assistant 且有文本(覆盖超快回复:before 已含该条)
  144. last = msgs[-1]
  145. if last.get("info", {}).get("role") == "assistant":
  146. lt = [p.get("text", "") for p in last.get("parts", [])
  147. if p.get("type") == "text" and (p.get("text") or "").strip()]
  148. if lt:
  149. return lt[-1]
  150. # 情况2:出现新增的 assistant 消息(条数 > 基线)
  151. if len(msgs) > before:
  152. for m in reversed(msgs):
  153. role = m.get("info", {}).get("role")
  154. if role == "assistant":
  155. txts = [p.get("text", "") for p in m.get("parts", [])
  156. if p.get("type") == "text" and (p.get("text") or "").strip()]
  157. if txts:
  158. return txts[-1]
  159. raise TimeoutError(f"等待 opencode 回复超时({timeout}s)")
  160. def ask(chat_id: str, text: str, timeout: float = 180) -> str:
  161. """高层封装:确保会话 → 发消息 → 等回复 → 返回最终文本。"""
  162. sid = ensure_session(chat_id)
  163. st, raw = send_prompt(sid, text)
  164. if st != 204:
  165. raise RuntimeError(f"发送到 opencode 失败 status={st}: {raw[:300]}")
  166. return wait_for_reply(sid, timeout=timeout)
  167. if __name__ == "__main__":
  168. if not sys.stdout.isatty():
  169. try:
  170. sys.stdout.reconfigure(encoding='utf-8', errors='replace')
  171. except Exception:
  172. pass
  173. test_chat = "test-opencode-client"
  174. reply = ask(test_chat, "请用一句话回复:收到消息了吗?")
  175. print(f"REPLY: {reply}")