plugin_api.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844
  1. """Yaoyao plugin backend routes for files, settings, voice, and group chat.
  2. Auto-mounted at /api/plugins/yaoyao/ by the dashboard plugin loader
  3. (web_server.py:17277). HTTP routes run behind the dashboard's session-token
  4. auth middleware just like core /api routes - no explicit auth dependency is
  5. needed (see the kanban plugin_api.py docstring for the authoritative note).
  6. Response field naming is camelCase to match iOS FileLibraryModels.swift
  7. CodingKeys (itemId / messageId / displayName / contentType / ...). The
  8. ``message-files/query`` payload shape matches the existing ArchivedMessageFile
  9. contract so iOS keeps working with only a path-string change.
  10. This file is loaded by path as a standalone module (web_server.py:17256
  11. spec_from_file_location), so it puts its own directory on sys.path and imports
  12. its companions (store, poller) as top-level modules.
  13. """
  14. from __future__ import annotations
  15. import hashlib
  16. import importlib.util
  17. import logging
  18. import os
  19. import sys
  20. import tempfile
  21. import json
  22. from pathlib import Path
  23. from typing import Any, Optional
  24. from fastapi import APIRouter, HTTPException, Query
  25. from fastapi.responses import FileResponse, StreamingResponse
  26. from pydantic import BaseModel, Field
  27. from urllib.parse import quote
  28. log = logging.getLogger("yaoyao.plugin_api")
  29. # Make sibling modules importable when this file is loaded by path.
  30. _THIS_DIR = str(Path(__file__).resolve().parent)
  31. if _THIS_DIR not in sys.path:
  32. sys.path.insert(0, _THIS_DIR)
  33. import store # noqa: E402
  34. import poller # noqa: E402
  35. import voice_store # noqa: E402
  36. def _profile_home(profile: Optional[str]) -> Path:
  37. """Resolve the Hermes profile home without consulting YaoYao state."""
  38. profile_name = str(profile or "default").strip() or "default"
  39. try:
  40. from hermes_constants import get_default_hermes_root # type: ignore
  41. root = Path(get_default_hermes_root())
  42. except Exception:
  43. configured = os.environ.get("HERMES_HOME", "").strip()
  44. root = Path(configured) if configured else Path.home() / ".hermes"
  45. if profile_name == "default":
  46. return root
  47. try:
  48. from hermes_cli.profiles import get_profile_dir # type: ignore
  49. return Path(get_profile_dir(profile_name))
  50. except Exception:
  51. return root / "profiles" / profile_name
  52. def _hermes_bot_name(profile: Optional[str], profile_home: Optional[Path] = None) -> str:
  53. """Read the Bot Mode title, then the core Hermes display name.
  54. Agent identity no longer falls back to YaoYao's legacy agent_settings.json.
  55. """
  56. try:
  57. import yaml # type: ignore
  58. path = (profile_home or _profile_home(profile)) / "profile.yaml"
  59. raw = yaml.safe_load(path.read_text(encoding="utf-8")) if path.is_file() else {}
  60. if not isinstance(raw, dict):
  61. return ""
  62. ui_meta = raw.get("ui_meta")
  63. bot_meta = ui_meta.get("hermes-bots") if isinstance(ui_meta, dict) else None
  64. candidates = [
  65. bot_meta.get("title") if isinstance(bot_meta, dict) else None,
  66. raw.get("display_name"),
  67. ]
  68. for candidate in candidates:
  69. if not isinstance(candidate, str):
  70. continue
  71. name = " ".join(candidate.split())
  72. if (
  73. name
  74. and len(name) <= store.MAX_AGENT_NAME_LENGTH
  75. and not any(ord(character) < 32 or ord(character) == 127 for character in name)
  76. and not store.is_reserved_mention_alias(name)
  77. ):
  78. return name
  79. except (OSError, TypeError, ValueError):
  80. pass
  81. except Exception as error:
  82. log.warning("Hermes Bot name load failed for %s: %s", profile, error)
  83. return ""
  84. def _hermes_bot_avatar(profile: Optional[str], profile_home: Optional[Path] = None) -> str:
  85. """Read the avatar stored with the Hermes-owned Bot metadata."""
  86. try:
  87. import yaml # type: ignore
  88. path = (profile_home or _profile_home(profile)) / "profile.yaml"
  89. raw = yaml.safe_load(path.read_text(encoding="utf-8")) if path.is_file() else {}
  90. if not isinstance(raw, dict):
  91. return ""
  92. ui_meta = raw.get("ui_meta")
  93. bot_meta = ui_meta.get("hermes-bots") if isinstance(ui_meta, dict) else None
  94. avatar = bot_meta.get("avatar") if isinstance(bot_meta, dict) else None
  95. if not isinstance(avatar, str):
  96. return ""
  97. avatar = avatar.strip()
  98. if len(avatar) > 512_000 or not avatar.startswith("data:image/"):
  99. return ""
  100. return avatar
  101. except (OSError, TypeError, ValueError):
  102. pass
  103. except Exception as error:
  104. log.warning("Hermes Bot avatar load failed for %s: %s", profile, error)
  105. return ""
  106. def _load_group_plugin_api():
  107. module_path = Path(_THIS_DIR) / "group_plugin_api.py"
  108. module_name = (
  109. "_hermes_yaoyao_group_api_"
  110. + hashlib.sha256(str(module_path).encode("utf-8")).hexdigest()[:24]
  111. )
  112. loaded = sys.modules.get(module_name)
  113. if loaded is not None:
  114. loaded_path = getattr(loaded, "__file__", None)
  115. if loaded_path is None or Path(loaded_path).resolve() != module_path.resolve():
  116. raise ImportError("Yaoyao group API module identity is invalid")
  117. return loaded
  118. spec = importlib.util.spec_from_file_location(module_name, module_path)
  119. if spec is None or spec.loader is None:
  120. raise ImportError("Unable to load Yaoyao group API")
  121. module = importlib.util.module_from_spec(spec)
  122. sys.modules[module_name] = module
  123. try:
  124. spec.loader.exec_module(module)
  125. except BaseException:
  126. sys.modules.pop(module_name, None)
  127. raise
  128. return module
  129. group_plugin_api = _load_group_plugin_api()
  130. group_plugin_api.set_agent_name_resolver(
  131. lambda profile: _hermes_bot_name(profile)
  132. )
  133. def _store_for(profile: Optional[str]):
  134. """Resolve the Store for ``profile`` (default if None). Raises 404 if the
  135. profile is unknown. Validates against the live profile set so a typo or a
  136. not-yet-created profile returns 404 instead of silently creating an empty
  137. data dir."""
  138. if not profile or profile == "default":
  139. return store.get_store(store.DEFAULT_DATA_ROOT)
  140. # Validate: the profile must exist on disk. Use profiles_to_serve to avoid
  141. # importing hermes_cli.profiles when not needed, but fall back to a path
  142. # check.
  143. try:
  144. from hermes_cli.profiles import profile_exists # type: ignore
  145. if not profile_exists(profile):
  146. raise HTTPException(status_code=404, detail=f"unknown profile: {profile}")
  147. except HTTPException:
  148. raise
  149. except Exception:
  150. # hermes_cli not importable (standalone runtime): best-effort path check.
  151. data_root = store.data_root_for_profile(profile)
  152. if not data_root.parent.parent.parent.is_dir(): # <home>/plugins/yaoyao
  153. raise HTTPException(status_code=404, detail=f"unknown profile: {profile}")
  154. return store.get_store(store.data_root_for_profile(profile))
  155. router = APIRouter()
  156. router.include_router(group_plugin_api.router)
  157. # Start the background archiver as soon as the module loads (the dashboard
  158. # loader imports this file once at server startup). Idempotent.
  159. try:
  160. poller.start()
  161. except Exception:
  162. log.exception("yaoyao: poller failed to start (routes still serve existing data)")
  163. # ---------------------------------------------------------------------------
  164. # Request / response models
  165. # ---------------------------------------------------------------------------
  166. class MessageFileQueryRequest(BaseModel):
  167. # Field names mirror iOS FileLibraryModels.swift MessageFileArchiveQueryRequest
  168. # CodingKeys: contextType / contextId / messageIds (camelCase).
  169. context_type: str = Field(default="session", alias="contextType")
  170. context_id: Optional[str] = Field(default=None, alias="contextId")
  171. message_ids: list[int] = Field(default_factory=list, alias="messageIds")
  172. model_config = {"populate_by_name": True}
  173. class UploadBody(BaseModel):
  174. # Reserved for the future upload endpoint; kept here for schema stability.
  175. display_name: Optional[str] = None
  176. class SessionContextSnapshotBody(BaseModel):
  177. used_tokens: int = Field(alias="usedTokens", gt=0)
  178. limit_tokens: Optional[int] = Field(default=None, alias="limitTokens", gt=0)
  179. percent: Optional[float] = Field(default=None, ge=0, le=100)
  180. compressions: Optional[int] = Field(default=None, ge=0)
  181. model: Optional[str] = Field(default=None, max_length=4096)
  182. provider: Optional[str] = Field(default=None, max_length=4096)
  183. observed_at: float = Field(alias="observedAt", gt=0)
  184. model_config = {"populate_by_name": True}
  185. # ---------------------------------------------------------------------------
  186. # Endpoints
  187. # ---------------------------------------------------------------------------
  188. def _kind_of(item: dict) -> str:
  189. return store.infer_kind(item["displayName"], item["contentType"])
  190. def _to_file_library_item(it: dict) -> dict:
  191. """Render an attachment row as an iOS FileLibraryItem-compatible payload.
  192. Field names match FileLibraryItem.CodingKeys (FileLibraryModels.swift):
  193. id / fileId / path / name / extension / mimeType / size / modifiedAt /
  194. exists / firstSeenAt / lastSeenAt / messageTimestamp / origins. The origin
  195. block matches FileLibraryOrigin.CodingKeys.
  196. """
  197. name = it["displayName"]
  198. ext = os.path.splitext(name)[1] # includes the leading "."
  199. ts = it["discoveredAt"]
  200. sender = it.get("sender") or "agent"
  201. author_kind = "user" if sender == "user" else "agent"
  202. source_kind = "agent_generated" if sender == "agent" else "upload"
  203. event_kind = "generated" if sender == "agent" else "uploaded"
  204. session_id = it.get("sessionId") or ""
  205. origin = {
  206. "id": it["id"],
  207. "sourceKind": source_kind,
  208. "eventKind": event_kind,
  209. "userId": None,
  210. "username": "",
  211. "profile": "",
  212. "agent": "",
  213. "agentProfile": None,
  214. "agentDisplayName": None,
  215. "authorKind": author_kind,
  216. "authorId": None,
  217. "authorName": None,
  218. "contextType": "session",
  219. "sessionId": session_id,
  220. "sessionTitle": "",
  221. "roomId": "",
  222. "roomName": "",
  223. "messageId": str(it["sourceMessageId"])
  224. if it.get("sourceMessageId") is not None
  225. else "",
  226. "runId": "",
  227. "workspace": "",
  228. "originalPath": "",
  229. "observedAt": ts,
  230. "messageTimestamp": ts,
  231. "messageSequence": None,
  232. "messageOrdinal": None,
  233. }
  234. return {
  235. "id": it["id"],
  236. "fileId": None,
  237. "path": f"yaoyao-file-library://archive/{it['id']}?name={quote(name, safe='')}",
  238. "name": name,
  239. "extension": ext,
  240. "mimeType": it.get("contentType") or "application/octet-stream",
  241. "size": it.get("byteCount", 0),
  242. "modifiedAt": ts,
  243. "exists": True,
  244. "firstSeenAt": ts,
  245. "lastSeenAt": ts,
  246. "messageTimestamp": ts,
  247. "origins": [origin],
  248. }
  249. @router.get("/profiles")
  250. def list_profiles():
  251. """Profiles with an existing state.db (i.e. ones the poller watches).
  252. Returns ``[{name, label, botName, avatar, isDefault}]``. ``botName`` comes from
  253. Hermes Bot Mode (then the core profile display name), never YaoYao's
  254. legacy Agent-name setting. ``default`` is always first when present.
  255. """
  256. pairs = poller._discover_profiles()
  257. out = []
  258. for name, home in pairs:
  259. bot_name = _hermes_bot_name(name, Path(home))
  260. avatar = _hermes_bot_avatar(name, Path(home))
  261. out.append(
  262. {
  263. "name": name,
  264. "label": bot_name or ("默认" if name == "default" else name),
  265. "botName": bot_name,
  266. # Compatibility alias for older YaoYao clients. Its value is
  267. # still Hermes-owned Bot metadata, not agent_settings.json.
  268. "agentName": bot_name,
  269. "avatar": avatar,
  270. "isDefault": name == "default",
  271. }
  272. )
  273. return {"profiles": out}
  274. @router.get("/files")
  275. def list_files(
  276. sender: Optional[str] = Query(None, description="agent | user"),
  277. kind: Optional[str] = Query(None, description="image | video | text | file"),
  278. session_id: Optional[str] = Query(None),
  279. search: Optional[str] = Query(None),
  280. profile: Optional[str] = Query(
  281. None, description="which agent's library to read (default if omitted)"
  282. ),
  283. limit: int = Query(50, ge=1, le=200),
  284. cursor: Optional[int] = Query(
  285. None, description="last item id seen, for pagination"
  286. ),
  287. ):
  288. """Page over the archive, newest first. Powers both iOS list and the dashboard tab.
  289. Each item is shaped as an iOS FileLibraryItem so the iOS list service can
  290. decode the response directly. ``profile`` selects which agent's library to
  291. read; omitted -> the default profile.
  292. """
  293. st = _store_for(profile)
  294. items, next_cursor, total = st.query_attachments(
  295. sender=sender,
  296. kind=kind,
  297. session_id=session_id,
  298. search=search,
  299. limit=limit,
  300. cursor=cursor,
  301. )
  302. return {
  303. "items": [_to_file_library_item(it) for it in items],
  304. "nextCursor": str(next_cursor) if next_cursor is not None else None,
  305. "total": total,
  306. "profile": profile or "default",
  307. }
  308. @router.get("/stats")
  309. def get_stats(
  310. profile: Optional[str] = Query(
  311. None, description="which agent's library to read (default if omitted)"
  312. ),
  313. ):
  314. """Archive summary: counts and total bytes for one profile."""
  315. return _store_for(profile).stats()
  316. @router.get("/session-context/{session_id}")
  317. def get_session_context(
  318. session_id: str,
  319. profile: Optional[str] = Query(default=None),
  320. ):
  321. """Return the last confirmed context-window reading for one chat."""
  322. snapshot = _store_for(profile).get_session_context(session_id)
  323. return {"snapshot": snapshot}
  324. @router.put("/session-context/{session_id}")
  325. def put_session_context(
  326. session_id: str,
  327. body: SessionContextSnapshotBody,
  328. profile: Optional[str] = Query(default=None),
  329. ):
  330. """Persist a monotonic, profile-scoped context-window reading."""
  331. snapshot = _store_for(profile).upsert_session_context(
  332. session_id,
  333. context_used=body.used_tokens,
  334. context_limit=body.limit_tokens,
  335. context_percent=body.percent,
  336. compressions=body.compressions,
  337. model=body.model,
  338. provider=body.provider,
  339. observed_at=body.observed_at,
  340. )
  341. return {"snapshot": snapshot}
  342. @router.get("/{item_id}")
  343. def get_item(
  344. item_id: int,
  345. profile: Optional[str] = Query(
  346. None, description="which agent's library holds this item"
  347. ),
  348. ):
  349. att = _store_for(profile).get_attachment(item_id)
  350. if att is None:
  351. raise HTTPException(status_code=404, detail="attachment not found")
  352. return {**att, "kind": _kind_of(att)}
  353. @router.get("/{item_id}/download")
  354. def download_item(
  355. item_id: int,
  356. profile: Optional[str] = Query(
  357. None, description="which agent's library holds this item"
  358. ),
  359. ):
  360. """Stream the archived file body. Arbitrary extension; behind dashboard auth.
  361. The static /dashboard-plugins/<name>/<file> route rejects non-browser
  362. suffixes (.pdf/.zip/...) and is unauthenticated, so arbitrary file
  363. downloads must live here as a FileResponse under /api/plugins/yaoyao/.
  364. """
  365. st = _store_for(profile)
  366. att = st.get_attachment(item_id)
  367. if att is None:
  368. raise HTTPException(status_code=404, detail="attachment not found")
  369. storage_name = st.get_object_storage_name(att["objectSha256"])
  370. if storage_name is None:
  371. raise HTTPException(status_code=404, detail="object body missing")
  372. path = st.objects_dir / storage_name
  373. if not path.is_file():
  374. raise HTTPException(status_code=404, detail="object body not on disk")
  375. return FileResponse(
  376. path=str(path),
  377. filename=att["displayName"],
  378. media_type=att["contentType"] or "application/octet-stream",
  379. )
  380. @router.post("/message-files/query")
  381. def query_message_files(
  382. req: MessageFileQueryRequest,
  383. profile: Optional[str] = Query(
  384. None, description="which agent's library to read (default if omitted)"
  385. ),
  386. ):
  387. """Batch-resolve archived files for a set of message ids.
  388. Mirrors the legacy /api/hermes/file-library/message-files/query contract:
  389. request {contextType, contextId, messageIds}, response
  390. {messages: {<messageId>: [file, ...]}} where each file's fields match
  391. iOS ArchivedMessageFile (camelCase) so a path-string change is the only
  392. iOS delta needed.
  393. ``profile`` selects which agent's library to read; omitted -> the default
  394. profile. Read from the query string so iOS callers that route through the
  395. shared HermesRESTClient (which sends profile via the X-Hermes-Profile
  396. header) can pass it explicitly as a query item.
  397. """
  398. st = _store_for(profile)
  399. grouped = st.query_message_files(req.message_ids)
  400. out: dict[str, list[dict]] = {}
  401. for mid, files in grouped.items():
  402. out[str(mid)] = [
  403. {
  404. "itemId": f["id"],
  405. "messageId": str(f["sourceMessageId"])
  406. if f["sourceMessageId"] is not None
  407. else "",
  408. "ordinal": idx,
  409. "originalPath": "",
  410. "referencePath": f"/api/plugins/yaoyao/{f['id']}/download",
  411. "name": f["displayName"],
  412. "size": f["byteCount"],
  413. "mimeType": f["contentType"],
  414. "archiveStatus": "ready",
  415. "archivedAt": f["discoveredAt"],
  416. "availability": "archived",
  417. }
  418. for idx, f in enumerate(files)
  419. ]
  420. return {"messages": out}
  421. @router.post("/rescan")
  422. def rescan():
  423. """Force one immediate poll cycle (handy after a big agent run)."""
  424. try:
  425. poller.poll_now()
  426. except Exception:
  427. log.debug("rescan: poll_now failed", exc_info=True)
  428. return store.stats()
  429. # ===========================================================================
  430. # Legacy Agent display-name settings
  431. #
  432. # The profile id stays the stable routing key. ``agentName`` is an optional,
  433. # per-profile presentation value retained for older clients. Group Agent
  434. # identity and current clients use Hermes Bot metadata instead.
  435. #
  436. # GET /agent/settings?profile=<profile>
  437. # PUT /agent/settings?profile=<profile> <- {"agentName": "..."}
  438. # ===========================================================================
  439. @router.get("/agent/settings")
  440. def get_agent_settings(
  441. profile: Optional[str] = Query(
  442. None, description="Hermes profile (default if omitted)"
  443. ),
  444. ):
  445. """Read one profile's configured Agent display name."""
  446. _store_for(profile)
  447. return store.load_agent_settings(profile)
  448. @router.put("/agent/settings")
  449. def put_agent_settings(
  450. body: dict,
  451. profile: Optional[str] = Query(
  452. None, description="Hermes profile (default if omitted)"
  453. ),
  454. ):
  455. """Set or clear one profile's Agent display name."""
  456. _store_for(profile)
  457. try:
  458. return store.save_agent_settings(profile, body)
  459. except ValueError as error:
  460. raise HTTPException(status_code=400, detail=str(error)) from error
  461. # ===========================================================================
  462. # iOS duplex voice settings
  463. #
  464. # Mirrors the yaoyao-webui ``ios-duplex-voice`` contract: one shared API key,
  465. # a voice list (id + name), and the currently selected voice. The iOS app
  466. # connects directly to the TTS provider (e.g. Volcano/doubao) for low-latency
  467. # duplex voice; this plugin stores the configuration it downloads.
  468. #
  469. # Endpoints (matching yaoyao-webui's /api/app/duplex-voice/* shape):
  470. # GET /voice/settings -> {hasApiKey, voices, currentVoiceId, updatedAt}
  471. # PUT /voice/settings <- {apiKey?, voices, currentVoiceId}
  472. # GET /voice/runtime -> {apiKey, voices, currentVoiceId, updatedAt}
  473. # PUT /voice/current-voice <- {currentVoiceId}
  474. # ===========================================================================
  475. @router.get("/voice/settings")
  476. def get_duplex_settings():
  477. """Public settings for the dashboard UI (apiKey masked as hasApiKey)."""
  478. return store.public_duplex_voice()
  479. @router.put("/voice/settings")
  480. def put_duplex_settings(body: dict):
  481. """Update duplex voice settings. Partial: omit apiKey to keep existing.
  482. Returns public settings (apiKey masked as hasApiKey), matching
  483. yaoyao-webui's saveSettings controller."""
  484. try:
  485. store.save_duplex_voice(body)
  486. except ValueError as e:
  487. raise HTTPException(status_code=400, detail=str(e))
  488. return store.public_duplex_voice()
  489. @router.get("/voice/runtime")
  490. def get_duplex_runtime():
  491. """Runtime settings for the iOS app (includes the real apiKey)."""
  492. return store.runtime_duplex_voice()
  493. @router.put("/voice/current-voice")
  494. def put_duplex_current_voice(body: dict):
  495. """Quick-switch the current voice without re-sending the full list.
  496. Returns {voices, currentVoiceId, updatedAt} (no apiKey fields), matching
  497. yaoyao-webui's saveCurrentVoice controller."""
  498. try:
  499. store.save_duplex_voice({"currentVoiceId": body.get("currentVoiceId")})
  500. except ValueError as e:
  501. raise HTTPException(status_code=400, detail=str(e))
  502. s = store.load_duplex_voice()
  503. return {
  504. "voices": s["voices"],
  505. "currentVoiceId": s["currentVoiceId"],
  506. "updatedAt": s["updatedAt"],
  507. }
  508. # ===========================================================================
  509. # TTS provider settings (mirrors yaoyao-webui's /api/hermes/tts/settings/*)
  510. # ===========================================================================
  511. @router.get("/tts/settings")
  512. def tts_list_settings():
  513. """List all configured TTS providers + the active one."""
  514. return voice_store.tts_list_settings()
  515. @router.put("/tts/settings/active")
  516. def tts_set_active(body: dict):
  517. try:
  518. return {"activeProvider": voice_store.tts_set_active(body.get("provider", ""))}
  519. except ValueError as e:
  520. raise HTTPException(status_code=400, detail=str(e))
  521. @router.put("/tts/settings/{provider}")
  522. def tts_save_provider(provider: str, body: dict):
  523. try:
  524. row = voice_store.tts_save_provider(
  525. provider, body.get("settings"), body.get("secrets")
  526. )
  527. # Optionally set active
  528. if "activeProvider" in body:
  529. voice_store.tts_set_active(body["activeProvider"])
  530. return {"setting": row, "activeProvider": voice_store.tts_get_active()}
  531. except ValueError as e:
  532. raise HTTPException(status_code=400, detail=str(e))
  533. @router.delete("/tts/settings/{provider}")
  534. def tts_delete_provider(provider: str):
  535. try:
  536. deleted = voice_store.tts_delete_provider(provider)
  537. return {
  538. "success": True,
  539. "deleted": deleted,
  540. "activeProvider": voice_store.tts_get_active(),
  541. }
  542. except ValueError as e:
  543. raise HTTPException(status_code=400, detail=str(e))
  544. @router.delete("/tts/settings/{provider}/base-url-preset")
  545. def tts_delete_base_url_preset(provider: str, url: str = Query(...)):
  546. row = voice_store.tts_delete_base_url_preset(provider, url)
  547. if row is None:
  548. raise HTTPException(status_code=404, detail="provider not found")
  549. return {"success": True, "setting": row}
  550. @router.delete("/tts/settings/{provider}/secret/{secret_name}")
  551. def tts_clear_secret(provider: str, secret_name: str):
  552. try:
  553. row = voice_store.tts_clear_secret(provider, secret_name)
  554. if row is None:
  555. raise HTTPException(status_code=404, detail="provider not found")
  556. return {"success": True, "setting": row}
  557. except ValueError as e:
  558. raise HTTPException(status_code=400, detail=str(e))
  559. # ===========================================================================
  560. # STT provider settings (mirrors yaoyao-webui's /api/hermes/stt/settings/*)
  561. # ===========================================================================
  562. @router.get("/stt/settings")
  563. def stt_list_settings():
  564. """List all configured STT providers + the active one."""
  565. return voice_store.stt_list_settings()
  566. @router.put("/stt/settings/active")
  567. def stt_set_active(body: dict):
  568. try:
  569. return {"activeProvider": voice_store.stt_set_active(body.get("provider", ""))}
  570. except ValueError as e:
  571. raise HTTPException(status_code=400, detail=str(e))
  572. @router.put("/stt/settings/{provider}")
  573. def stt_save_provider(provider: str, body: dict):
  574. try:
  575. row = voice_store.stt_save_provider(
  576. provider, body.get("settings"), body.get("secrets")
  577. )
  578. if "activeProvider" in body:
  579. voice_store.stt_set_active(body["activeProvider"])
  580. return {"setting": row, "activeProvider": voice_store.stt_get_active()}
  581. except ValueError as e:
  582. raise HTTPException(status_code=400, detail=str(e))
  583. @router.delete("/stt/settings/{provider}")
  584. def stt_delete_provider(provider: str):
  585. deleted = voice_store.stt_delete_provider(provider)
  586. return {
  587. "success": True,
  588. "deleted": deleted,
  589. "activeProvider": voice_store.stt_get_active(),
  590. }
  591. @router.delete("/stt/settings/{provider}/base-url-preset")
  592. def stt_delete_base_url_preset(provider: str, url: str = Query(...)):
  593. row = voice_store.stt_delete_base_url_preset(provider, url)
  594. if row is None:
  595. raise HTTPException(status_code=404, detail="provider not found")
  596. return {"success": True, "setting": row}
  597. @router.delete("/stt/settings/{provider}/secret/{secret_name}")
  598. def stt_clear_secret(provider: str, secret_name: str):
  599. try:
  600. row = voice_store.stt_clear_secret(provider, secret_name)
  601. if row is None:
  602. raise HTTPException(status_code=404, detail="provider not found")
  603. return {"success": True, "setting": row}
  604. except ValueError as e:
  605. raise HTTPException(status_code=400, detail=str(e))
  606. # ===========================================================================
  607. # Provider info + probe
  608. # ===========================================================================
  609. @router.get("/voice/providers-info")
  610. def get_providers_info():
  611. """Static provider metadata for the frontend picker."""
  612. return voice_store.provider_info()
  613. class ProbeRequest(BaseModel):
  614. kind: str # 'tts' | 'stt'
  615. provider: Optional[str] = None
  616. compatibility: Optional[str] = None # 'openai-compatible' | 'manual'
  617. baseUrl: Optional[str] = None
  618. apiKey: Optional[str] = None
  619. @router.post("/voice/probe")
  620. def probe_provider(req: ProbeRequest):
  621. """Probe a TTS/STT provider's /models endpoint to validate connectivity.
  622. Mirrors yaoyao-webui's /api/voice/providers/probe. For 'manual' mode,
  623. just validates the baseUrl. For 'openai-compatible', fetches <baseUrl>/models.
  624. """
  625. import urllib.request
  626. import urllib.error
  627. compatibility = req.compatibility or "openai-compatible"
  628. base_url = (req.baseUrl or "").strip()
  629. if not base_url:
  630. raise HTTPException(status_code=400, detail="baseUrl is required")
  631. if not (base_url.startswith("http://") or base_url.startswith("https://")):
  632. raise HTTPException(status_code=400, detail="baseUrl must be http(s)")
  633. if compatibility == "manual":
  634. return {
  635. "ok": True,
  636. "models": [],
  637. "recommendedModel": "",
  638. "errorSummary": "",
  639. "manualModelAllowed": True,
  640. "normalizedBaseUrl": base_url,
  641. }
  642. # openai-compatible: fetch /models
  643. api_key = req.apiKey or ""
  644. url = base_url.rstrip("/") + "/models"
  645. headers = {"Content-Type": "application/json"}
  646. if api_key:
  647. headers["Authorization"] = f"Bearer {api_key}"
  648. try:
  649. request = urllib.request.Request(url, headers=headers, method="GET")
  650. with urllib.request.urlopen(request, timeout=10) as resp:
  651. body = resp.read().decode("utf-8", errors="replace")
  652. data = json.loads(body)
  653. raw_models = data.get("data", []) if isinstance(data, dict) else []
  654. models = []
  655. for m in raw_models[:100]:
  656. mid = m.get("id", "") if isinstance(m, dict) else str(m)
  657. if not mid:
  658. continue
  659. mid_lower = mid.lower()
  660. # TTS vs STT preference
  661. if req.kind == "tts":
  662. pref = any(
  663. k in mid_lower
  664. for k in (
  665. "tts",
  666. "speech",
  667. "audio",
  668. "voice",
  669. "playai",
  670. "orpheus",
  671. )
  672. )
  673. else:
  674. pref = any(
  675. k in mid_lower
  676. for k in ("whisper", "transcrib", "stt", "speech-to-text")
  677. )
  678. models.append(
  679. {
  680. "id": mid,
  681. "label": mid,
  682. "capability": "preferred" if pref else "other",
  683. }
  684. )
  685. # Sort: preferred first
  686. models.sort(key=lambda m: 0 if m["capability"] == "preferred" else 1)
  687. recommended = models[0]["id"] if models else ""
  688. return {
  689. "ok": True,
  690. "models": models,
  691. "recommendedModel": recommended,
  692. "errorSummary": "",
  693. "manualModelAllowed": True,
  694. "normalizedBaseUrl": base_url,
  695. }
  696. except urllib.error.HTTPError as e:
  697. return {
  698. "ok": False,
  699. "models": [],
  700. "recommendedModel": "",
  701. "errorSummary": f"HTTP {e.code}",
  702. "errorDetails": e.read().decode("utf-8", errors="replace")[:500],
  703. "manualModelAllowed": True,
  704. "normalizedBaseUrl": base_url,
  705. }
  706. except Exception as e:
  707. return {
  708. "ok": False,
  709. "models": [],
  710. "recommendedModel": "",
  711. "errorSummary": str(e)[:200],
  712. "manualModelAllowed": True,
  713. "normalizedBaseUrl": base_url,
  714. }