audio_server.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. """
  2. audio_server.py — AI有声书 MCP 工具集(纯模块,无 I/O)
  3. 为什么从可执行脚本变成纯模块:
  4. Windows + Python 3.14 上,mcp_pipe.py 通过 subprocess.Popen + stdio PIPE
  5. 启动子进程时,child 的 stdin 永远读不到数据(pipe 怪行为)。所以本工具
  6. 改成纯模块,让 mcp_pipe.py 直接 import,避免任何跨进程通信。
  7. 提供的接口:
  8. - TOOLS:MCP 工具 schema 列表
  9. - handle_request(req) -> response | None:处理 xiaozhi 发来的 JSON-RPC 请求
  10. (notifications 返回 None,request 返回 response dict)
  11. """
  12. from __future__ import annotations
  13. import json
  14. import logging
  15. import os
  16. import sys
  17. import urllib.parse
  18. import urllib.request
  19. from typing import Any
  20. API_BASE = os.environ.get("AUDIOBOOK_API_BASE", "http://127.0.0.1:3000")
  21. API_TIMEOUT = float(os.environ.get("AUDIOBOOK_API_TIMEOUT", "15"))
  22. MAX_RESP_BYTES = 900
  23. logger = logging.getLogger("audiobook_mcp")
  24. # ---------------- 同步 HTTP 客户端 ----------------
  25. def _http_get(path: str, params: dict | None = None) -> dict:
  26. url = f"{API_BASE}{path}"
  27. if params:
  28. url += "?" + urllib.parse.urlencode(params)
  29. try:
  30. with urllib.request.urlopen(url, timeout=API_TIMEOUT) as r:
  31. return json.loads(r.read().decode("utf-8"))
  32. except Exception as e: # noqa: BLE001
  33. return {"code": -1, "message": f"http error: {e}"}
  34. def _http_post(path: str, body: dict | None = None) -> dict:
  35. url = f"{API_BASE}{path}"
  36. data = json.dumps(body or {}).encode("utf-8")
  37. try:
  38. req = urllib.request.Request(url, data=data, method="POST",
  39. headers={"Content-Type": "application/json"})
  40. with urllib.request.urlopen(req, timeout=API_TIMEOUT) as r:
  41. return json.loads(r.read().decode("utf-8"))
  42. except Exception as e: # noqa: BLE001
  43. return {"code": -1, "message": f"http error: {e}"}
  44. # ---------------- 工具实现 ----------------
  45. def _truncate(text: str, max_bytes: int = MAX_RESP_BYTES) -> str:
  46. """仅在可信的短文本上使用——不要对 JSON 整体截断。"""
  47. enc = text.encode("utf-8")
  48. if len(enc) <= max_bytes:
  49. return text
  50. return enc[:max_bytes].decode("utf-8", errors="ignore") + "..."
  51. def _ok(payload: Any) -> dict:
  52. # 不截断外层 JSON——工具层已控制内容长度(text 截断到 250~300 字符)
  53. return {"success": True, "result": json.dumps(payload, ensure_ascii=False, separators=(",", ":"))}
  54. def _err(msg: str) -> dict:
  55. return {"success": False, "error": _truncate(msg, 400)}
  56. def tool_search_audiobooks(args: dict) -> dict:
  57. keywords = (args.get("keywords") or "").strip()
  58. if not keywords:
  59. return _err("搜索关键词不能为空")
  60. data = _http_get("/api/search", {"q": keywords, "limit": "8"})
  61. items = (data.get("data") or [])[:8]
  62. return _ok({
  63. "count": len(items),
  64. "items": [{"id": it.get("id") or it.get("audioId") or it.get("bookId"),
  65. "title": it.get("title") or it.get("name") or "",
  66. "desc": (it.get("description") or it.get("summary") or "")[:120]}
  67. for it in items],
  68. "tip": "用 get_book_details(book_id) 查看章节",
  69. })
  70. def tool_list_categories(args: dict) -> dict:
  71. data = _http_get("/api/categories")
  72. items = data.get("data") or []
  73. return _ok({"count": len(items),
  74. "categories": [{"id": it.get("id"), "name": it.get("name") or it.get("title")}
  75. for it in items]})
  76. def tool_list_books_by_category(args: dict) -> dict:
  77. cid = args.get("category_id")
  78. if not cid:
  79. return _err("category_id 必填")
  80. data = _http_get(f"/api/categories/{int(cid)}",
  81. {"page": str(args.get("page") or 1), "pageSize": "10"})
  82. info = data.get("data") or {}
  83. items = info.get("list") if isinstance(info, dict) else info
  84. if not isinstance(items, list):
  85. items = []
  86. return _ok({"category_id": cid, "count": len(items[:10]),
  87. "books": [{"id": it.get("id") or it.get("bookId"),
  88. "title": it.get("title") or "",
  89. "desc": (it.get("description") or "")[:120]}
  90. for it in items[:10]]})
  91. def tool_get_book_details(args: dict) -> dict:
  92. bid = args.get("book_id")
  93. if not bid:
  94. return _err("book_id 必填")
  95. data = _http_get(f"/api/book-generator/books/{int(bid)}")
  96. if data.get("code") != 0:
  97. return _err("书籍不存在或无权访问,可用 search_audiobooks 重新搜索或用 generate_audiobook 生成新书")
  98. info = data.get("data") or {}
  99. chapters = info.get("chapters") or []
  100. stage = info.get("genStage") or ""
  101. progress = info.get("progress") or 0
  102. # 阶段→中文提示
  103. stage_hint = {"outlining": "正在生成大纲", "content_generating": "正在写内容",
  104. "content_completed": "内容已生成", "audio_generating": "正在生成音频",
  105. "audio_completed": "全部完成"}.get(stage, stage)
  106. result = {
  107. "id": info.get("id"),
  108. "title": info.get("title"),
  109. "progress": progress,
  110. "stage": stage_hint,
  111. "total_chapters": info.get("totalChapters"),
  112. "chapters": [{"id": ch.get("id"), "title": ch.get("title"),
  113. "has_audio": bool(ch.get("audioUrl") and not str(ch.get("audioUrl", "")).startswith("/uploads/"))}
  114. for ch in chapters[:30]],
  115. }
  116. if chapters:
  117. result["action"] = "有%d章内容,调get_chapter_text(chapter_id=%d)开始朗读" % (len(chapters), chapters[0]["id"])
  118. else:
  119. result["action"] = "还没生成完(%s %d%%),告诉用户'正在%s,进度%d%%,说继续查看'。用户说继续后再调本工具" % (stage_hint, progress, stage_hint, progress)
  120. return _ok(result)
  121. def _chapter_nav(book_id: int, chapter_id: int) -> dict:
  122. """获取章节导航:第几章/共几章/下一章ID/上一章ID"""
  123. nav = {"chapter_index": 1, "total_chapters": 1, "next_chapter_id": None, "prev_chapter_id": None}
  124. try:
  125. bdata = _http_get(f"/api/book-generator/books/{book_id}")
  126. chapters = (bdata.get("data") or {}).get("chapters") or []
  127. ids = [c["id"] for c in chapters if c.get("id")]
  128. nav["total_chapters"] = len(ids)
  129. if chapter_id in ids:
  130. idx = ids.index(chapter_id)
  131. nav["chapter_index"] = idx + 1
  132. if idx > 0:
  133. nav["prev_chapter_id"] = ids[idx - 1]
  134. if idx < len(ids) - 1:
  135. nav["next_chapter_id"] = ids[idx + 1]
  136. except Exception: # noqa: BLE001
  137. pass
  138. return nav
  139. def tool_get_chapter_audio_url(args: dict) -> dict:
  140. """【核心】获取章节音频URL。音箱用此URL直接播放。
  141. 返回章节音频地址+导航信息(下一章ID、共几章)。播完当前章节后,
  142. 请自动调用 get_chapter_audio_url(chapter_id=下一章ID) 继续播放下一章。"""
  143. cid = args.get("chapter_id")
  144. if not cid:
  145. return _err("chapter_id 必填")
  146. data = _http_get(f"/api/book-generator/books/chapters/{int(cid)}")
  147. info = data.get("data") or {}
  148. audio_url = info.get("audioUrl") or ""
  149. book_id = info.get("bookId")
  150. if not audio_url or audio_url.startswith("/uploads/"):
  151. # 无音频 → 自动降级为文本朗读,不让小智卡在错误上
  152. return tool_get_chapter_text({"chapter_id": cid, "max_chars": 200})
  153. nav = _chapter_nav(int(book_id) if book_id else 0, int(cid))
  154. result = {
  155. "chapter_id": cid,
  156. "title": info.get("title"),
  157. "audio_url": audio_url,
  158. "duration": info.get("audioDuration"),
  159. "book_id": book_id,
  160. **nav,
  161. }
  162. if nav["next_chapter_id"]:
  163. result["action"] = "播完本章后请自动调 get_chapter_audio_url(chapter_id=%s)" % nav["next_chapter_id"]
  164. else:
  165. result["action"] = "最后一章播完后告诉用户全部播放完毕并总结"
  166. return _ok(result)
  167. def tool_get_chapter_text(args: dict) -> dict:
  168. """读正文自己TTS(推荐用 read_chapter)。"""
  169. cid = args.get("chapter_id")
  170. if not cid:
  171. return _err("chapter_id 必填")
  172. cid = int(cid)
  173. data = _http_get(f"/api/book-generator/books/chapters/{cid}/read",
  174. {"format": "text", "max_chars": "300"})
  175. if data.get("code") != 0:
  176. return _err(data.get("message") or "读取失败")
  177. info = (data.get("data") or {})
  178. title = info.get("title") or ""
  179. text = info.get("text") or ""
  180. if not text:
  181. return _err("该章节暂无文本内容")
  182. nav = info.get("nav") or {}
  183. return {
  184. "action": "RESPONSE",
  185. "response": "%s。%s" % (title, text),
  186. "chapter_index": nav.get("chapter_index"),
  187. "total_chapters": nav.get("total_chapters"),
  188. "has_more": info.get("has_more"),
  189. **({"next_chapter_id": nav["next_chapter_id"]} if nav.get("next_chapter_id") else {}),
  190. }
  191. def tool_read_chapter(args: dict) -> dict:
  192. """统一读章节:format=audio 返回URL播放;text 返回正文自己TTS;auto 智能选(推荐)。"""
  193. cid = args.get("chapter_id")
  194. if not cid:
  195. return _err("chapter_id 必填")
  196. fmt = args.get("format") or "auto"
  197. if fmt not in ("audio", "text", "auto"):
  198. return _err("format 必须是 audio/text/auto")
  199. # 调新端点
  200. data = _http_get(f"/api/book-generator/books/chapters/{int(cid)}/read",
  201. {"format": fmt, "max_chars": "300"})
  202. code = data.get("code")
  203. # 2002 = on-demand TTS 失败 → 自动降级到 text 让音箱自己读
  204. if code == 2002:
  205. logger.warning("read_chapter TTS failed, fallback to text: %s", data.get("message"))
  206. data = _http_get(f"/api/book-generator/books/chapters/{int(cid)}/read",
  207. {"format": "text", "max_chars": "300"})
  208. elif code != 0:
  209. return _err(data.get("message") or "章节读取失败")
  210. info = (data.get("data") or {})
  211. nav = (info.get("nav") or {})
  212. if info.get("mode") == "audio":
  213. result = {
  214. "chapter_id": info["chapter_id"],
  215. "title": info.get("title"),
  216. "audio_url": info.get("audio_url"),
  217. "duration": info.get("audio_duration"),
  218. "audio_source": info.get("audio_source"), # 'full' 或 'on_demand'
  219. "book_id": info.get("book_id"),
  220. **nav,
  221. }
  222. next_id = nav.get("next_chapter_id")
  223. if next_id:
  224. result["action"] = "播完调 read_chapter(chapter_id=%s)" % next_id
  225. else:
  226. result["action"] = "最后一章,播完总结"
  227. return _ok(result)
  228. # text 模式
  229. text = info.get("text", "")
  230. title = info.get("title", "")
  231. return {
  232. "action": "RESPONSE",
  233. "response": "%s。%s" % (title, text),
  234. "has_more": info.get("has_more", False),
  235. "next_offset": info.get("next_offset"),
  236. "chapter_id": info["chapter_id"],
  237. **({"next_chapter_id": nav["next_chapter_id"]} if nav.get("next_chapter_id") else {}),
  238. }
  239. def tool_list_recent_history(args: dict) -> dict:
  240. limit = max(1, min(int(args.get("limit") or 10), 30))
  241. data = _http_get("/api/history/list", {"page": "1", "pageSize": str(limit)})
  242. info = data.get("data") or {}
  243. items = info.get("list") or []
  244. return _ok({"count": len(items[:limit]),
  245. "history": [{"id": it.get("id"), "title": it.get("title"),
  246. "type": it.get("type", "audio"),
  247. "created_at": it.get("createdAt")}
  248. for it in items[:limit]]})
  249. def _bg_poll_and_push(book_id: int, title: str, device_id: str):
  250. """后台轮询生成进度,完成后主动 push 到小智音箱"""
  251. import time as _time
  252. import urllib.request as _rq
  253. push_url = os.environ.get("XIAOZHI_PUSH_URL", "http://192.168.31.155:58003/xiaozhi/push")
  254. deadline = _time.time() + 1800
  255. interval = 10
  256. while _time.time() < deadline:
  257. _time.sleep(interval)
  258. detail = _http_get(f"/api/book-generator/books/{book_id}")
  259. info = detail.get("data") or {}
  260. chapters = info.get("chapters") or []
  261. progress = info.get("progress", 0)
  262. interval = 5 if progress > 30 else 10
  263. if chapters and progress >= 30:
  264. first_c = chapters[0]
  265. fd = _http_get(f"/api/book-generator/books/chapters/{first_c['id']}")
  266. first_content = ((fd.get("data") or {}).get("content") or (fd.get("data") or {}).get("text") or "")
  267. if first_content and len(first_content) > 50:
  268. clean = first_content.replace("#", "").replace("*", "").strip()
  269. first_title = first_c.get("title") or ""
  270. book_title = info.get("title") or title
  271. push_text = "《%s》已生成!共%d章。%s。%s" % (book_title, len(chapters), first_title, clean[:200])
  272. try:
  273. data = json.dumps({"device_id": device_id, "text": push_text, "type": "tts"}).encode()
  274. req = _rq.Request(push_url, data=data, method="POST",
  275. headers={"Content-Type": "application/json"})
  276. _rq.urlopen(req, timeout=10)
  277. logger.info("push done: book_id=%d", book_id)
  278. except Exception as e:
  279. logger.error("push failed: %s", e)
  280. return
  281. logger.warning("generate timeout: book_id=%d", book_id)
  282. def tool_generate_audiobook(args: dict) -> dict:
  283. """一键生成有声书。创建书+后台生成→立即返回。完成后自动推送通知。"""
  284. import threading
  285. title = (args.get("title") or "").strip()
  286. if not title:
  287. return _err("title 必填")
  288. create = _http_post("/api/book-generator/books", {"title": title, "description": args.get("description") or ""})
  289. book_id = ((create.get("data") or {}) or {}).get("id")
  290. if not book_id:
  291. return _err(f"创建失败: {create.get('message') or create}")
  292. _http_post(f"/api/book-generator/books/{book_id}/batch-generate",
  293. {"steps": ["generate_content", "generate_audio"]})
  294. # 通知 xiaozhi-server 注册后台轮询任务
  295. xz_url = os.environ.get("XIAOZHI_SERVER_URL", "http://host.docker.internal:58003/xiaozhi/book-callback")
  296. device_id = (args.get("device_id") or "").strip()
  297. if device_id:
  298. try:
  299. cb = json.dumps({"book_id": book_id, "device_id": device_id, "title": title}).encode()
  300. urllib.request.urlopen(urllib.request.Request(xz_url, data=cb, method="POST",
  301. headers={"Content-Type": "application/json"}), timeout=5)
  302. except Exception:
  303. pass
  304. return {
  305. "action": "RESPONSE",
  306. "response": "已开始生成《%s》(书号%d),大约需要5-10分钟。" % (title, book_id),
  307. "book_id": book_id,
  308. }
  309. # 精简工具 schema:7 个核心工具,目标 < 1024 字节。
  310. TOOLS: list[dict] = [
  311. {"name": "search_audiobooks",
  312. "description": "搜书。",
  313. "inputSchema": {"type": "object",
  314. "properties": {"keywords": {"type": "string"}},
  315. "required": ["keywords"]}},
  316. {"name": "list_categories",
  317. "description": "分类。",
  318. "inputSchema": {"type": "object", "properties": {}}},
  319. {"name": "get_book_details",
  320. "description": "书详情+章节。",
  321. "inputSchema": {"type": "object",
  322. "properties": {"book_id": {"type": "integer"}},
  323. "required": ["book_id"]}},
  324. {"name": "read_chapter",
  325. "description": "audio返URL播,text返正文,auto智能选。",
  326. "inputSchema": {"type": "object",
  327. "properties": {
  328. "chapter_id": {"type": "integer"},
  329. "format": {"type": "string", "enum": ["audio", "text", "auto"]},
  330. },
  331. "required": ["chapter_id"]}},
  332. {"name": "generate_audiobook",
  333. "description": "AI生成新书。异步,立即返回,完成后自动推送通知。",
  334. "inputSchema": {"type": "object",
  335. "properties": {"title": {"type": "string"}, "device_id": {"type": "string"}},
  336. "required": ["title"]}},
  337. ]
  338. TOOL_DISPATCH = {
  339. "search_audiobooks": tool_search_audiobooks,
  340. "list_categories": tool_list_categories,
  341. "get_book_details": tool_get_book_details,
  342. "read_chapter": tool_read_chapter,
  343. "generate_audiobook": tool_generate_audiobook,
  344. }
  345. SERVER_INFO = {"name": "AudioBook", "version": "1.0.0"}
  346. SERVER_CAPABILITIES = {"tools": {"listChanged": False}}
  347. PROTOCOL_VERSION = "2024-11-05"
  348. def handle_request(req: dict) -> dict | None:
  349. """处理一个 JSON-RPC 请求。
  350. - notification(无 id)返回 None
  351. - request 返回 response dict
  352. """
  353. method = req.get("method")
  354. req_id = req.get("id")
  355. params = req.get("params") or {}
  356. if req_id is None and method and not method.startswith("notifications/"):
  357. return {"jsonrpc": "2.0", "id": None,
  358. "error": {"code": -32600, "message": "invalid request"}}
  359. if method == "initialize":
  360. return {"jsonrpc": "2.0", "id": req_id, "result": {
  361. "protocolVersion": PROTOCOL_VERSION,
  362. "capabilities": SERVER_CAPABILITIES,
  363. "serverInfo": SERVER_INFO,
  364. }}
  365. if method == "ping":
  366. return {"jsonrpc": "2.0", "id": req_id, "result": {}}
  367. if method == "tools/list":
  368. return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
  369. if method == "tools/call":
  370. name = params.get("name")
  371. args = params.get("arguments") or {}
  372. handler = TOOL_DISPATCH.get(name)
  373. if not handler:
  374. return {"jsonrpc": "2.0", "id": req_id,
  375. "error": {"code": -32602, "message": f"unknown tool: {name}"}}
  376. try:
  377. result = handler(args)
  378. text = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
  379. return {"jsonrpc": "2.0", "id": req_id, "result": {
  380. "content": [{"type": "text", "text": text}],
  381. "isError": not result.get("success", True),
  382. }}
  383. except Exception as e: # noqa: BLE001
  384. logger.exception("tool %s failed", name)
  385. return {"jsonrpc": "2.0", "id": req_id, "result": {
  386. "content": [{"type": "text", "text": json.dumps(_err(str(e)), ensure_ascii=False)}],
  387. "isError": True,
  388. }}
  389. if method and method.startswith("notifications/"):
  390. return None
  391. return {"jsonrpc": "2.0", "id": req_id,
  392. "error": {"code": -32601, "message": f"method not found: {method}"}}
  393. # ---- stdio 入口(保留用于本地 stdio 调用,不通过 mcp_pipe) ----
  394. def main_stdio():
  395. """直接 stdio 模式启动一个 MCP server(用于 verify_local.py / test_stdio.py)"""
  396. logging.basicConfig(
  397. level=os.environ.get("MCP_LOG_LEVEL", "INFO"),
  398. format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
  399. stream=sys.stderr,
  400. )
  401. logger.info("AudioBook MCP server (stdio) starting, api_base=%s", API_BASE)
  402. while True:
  403. line = sys.stdin.buffer.readline()
  404. if not line:
  405. return
  406. try:
  407. req = json.loads(line.decode("utf-8").strip())
  408. except Exception as e: # noqa: BLE001
  409. err = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"parse error: {e}"}}
  410. sys.stdout.buffer.write((json.dumps(err, ensure_ascii=False) + "\n").encode("utf-8"))
  411. sys.stdout.buffer.flush()
  412. continue
  413. resp = handle_request(req)
  414. if resp is not None:
  415. sys.stdout.buffer.write((json.dumps(resp, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8"))
  416. sys.stdout.buffer.flush()
  417. if __name__ == "__main__":
  418. main_stdio()