plugin_api.py 30 KB

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