libre-webui 0.8.6 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/README.md +64 -46
  2. package/backend/dist/db.d.ts.map +1 -1
  3. package/backend/dist/db.js +21 -19
  4. package/backend/dist/db.js.map +1 -1
  5. package/backend/dist/index.d.ts.map +1 -1
  6. package/backend/dist/index.js +31 -944
  7. package/backend/dist/index.js.map +1 -1
  8. package/backend/dist/middleware/auth.d.ts.map +1 -1
  9. package/backend/dist/middleware/auth.js +7 -20
  10. package/backend/dist/middleware/auth.js.map +1 -1
  11. package/backend/dist/middleware/index.d.ts.map +1 -1
  12. package/backend/dist/middleware/index.js +4 -2
  13. package/backend/dist/middleware/index.js.map +1 -1
  14. package/backend/dist/models/personaModel.d.ts.map +1 -1
  15. package/backend/dist/models/personaModel.js +11 -9
  16. package/backend/dist/models/personaModel.js.map +1 -1
  17. package/backend/dist/routes/auth.d.ts.map +1 -1
  18. package/backend/dist/routes/auth.js +36 -14
  19. package/backend/dist/routes/auth.js.map +1 -1
  20. package/backend/dist/routes/chat.d.ts.map +1 -1
  21. package/backend/dist/routes/chat.js +61 -242
  22. package/backend/dist/routes/chat.js.map +1 -1
  23. package/backend/dist/routes/documents.d.ts.map +1 -1
  24. package/backend/dist/routes/documents.js +10 -8
  25. package/backend/dist/routes/documents.js.map +1 -1
  26. package/backend/dist/routes/embeddings.d.ts +3 -0
  27. package/backend/dist/routes/embeddings.d.ts.map +1 -0
  28. package/backend/dist/routes/embeddings.js +52 -0
  29. package/backend/dist/routes/embeddings.js.map +1 -0
  30. package/backend/dist/routes/imageGen.d.ts.map +1 -1
  31. package/backend/dist/routes/imageGen.js +9 -7
  32. package/backend/dist/routes/imageGen.js.map +1 -1
  33. package/backend/dist/routes/ollama.d.ts.map +1 -1
  34. package/backend/dist/routes/ollama.js +22 -239
  35. package/backend/dist/routes/ollama.js.map +1 -1
  36. package/backend/dist/routes/personas.d.ts.map +1 -1
  37. package/backend/dist/routes/personas.js +3 -1
  38. package/backend/dist/routes/personas.js.map +1 -1
  39. package/backend/dist/routes/plugins.d.ts.map +1 -1
  40. package/backend/dist/routes/plugins.js +7 -105
  41. package/backend/dist/routes/plugins.js.map +1 -1
  42. package/backend/dist/routes/tts.d.ts.map +1 -1
  43. package/backend/dist/routes/tts.js +7 -5
  44. package/backend/dist/routes/tts.js.map +1 -1
  45. package/backend/dist/routes/users.d.ts.map +1 -1
  46. package/backend/dist/routes/users.js +7 -5
  47. package/backend/dist/routes/users.js.map +1 -1
  48. package/backend/dist/services/assistantCompletionService.d.ts +26 -0
  49. package/backend/dist/services/assistantCompletionService.d.ts.map +1 -0
  50. package/backend/dist/services/assistantCompletionService.js +53 -0
  51. package/backend/dist/services/assistantCompletionService.js.map +1 -0
  52. package/backend/dist/services/authService.d.ts +2 -0
  53. package/backend/dist/services/authService.d.ts.map +1 -1
  54. package/backend/dist/services/authService.js +15 -7
  55. package/backend/dist/services/authService.js.map +1 -1
  56. package/backend/dist/services/chatGenerationService.d.ts +32 -0
  57. package/backend/dist/services/chatGenerationService.d.ts.map +1 -0
  58. package/backend/dist/services/chatGenerationService.js +118 -0
  59. package/backend/dist/services/chatGenerationService.js.map +1 -0
  60. package/backend/dist/services/chatRequestService.d.ts +55 -0
  61. package/backend/dist/services/chatRequestService.d.ts.map +1 -0
  62. package/backend/dist/services/chatRequestService.js +103 -0
  63. package/backend/dist/services/chatRequestService.js.map +1 -0
  64. package/backend/dist/services/chatService.d.ts +0 -1
  65. package/backend/dist/services/chatService.d.ts.map +1 -1
  66. package/backend/dist/services/chatService.js +17 -113
  67. package/backend/dist/services/chatService.js.map +1 -1
  68. package/backend/dist/services/documentService.d.ts.map +1 -1
  69. package/backend/dist/services/documentService.js +23 -21
  70. package/backend/dist/services/documentService.js.map +1 -1
  71. package/backend/dist/services/embeddingService.d.ts +9 -0
  72. package/backend/dist/services/embeddingService.d.ts.map +1 -0
  73. package/backend/dist/services/embeddingService.js +155 -0
  74. package/backend/dist/services/embeddingService.js.map +1 -0
  75. package/backend/dist/services/encryptionService.d.ts.map +1 -1
  76. package/backend/dist/services/encryptionService.js +26 -24
  77. package/backend/dist/services/encryptionService.js.map +1 -1
  78. package/backend/dist/services/galleryService.d.ts.map +1 -1
  79. package/backend/dist/services/galleryService.js +8 -6
  80. package/backend/dist/services/galleryService.js.map +1 -1
  81. package/backend/dist/services/memoryService.d.ts +2 -13
  82. package/backend/dist/services/memoryService.d.ts.map +1 -1
  83. package/backend/dist/services/memoryService.js +45 -211
  84. package/backend/dist/services/memoryService.js.map +1 -1
  85. package/backend/dist/services/memoryUtils.d.ts +29 -0
  86. package/backend/dist/services/memoryUtils.d.ts.map +1 -0
  87. package/backend/dist/services/memoryUtils.js +165 -0
  88. package/backend/dist/services/memoryUtils.js.map +1 -0
  89. package/backend/dist/services/mutationEngineService.d.ts.map +1 -1
  90. package/backend/dist/services/mutationEngineService.js +5 -3
  91. package/backend/dist/services/mutationEngineService.js.map +1 -1
  92. package/backend/dist/services/ollamaService.d.ts.map +1 -1
  93. package/backend/dist/services/ollamaService.js +67 -34
  94. package/backend/dist/services/ollamaService.js.map +1 -1
  95. package/backend/dist/services/personaService.d.ts.map +1 -1
  96. package/backend/dist/services/personaService.js +6 -2
  97. package/backend/dist/services/personaService.js.map +1 -1
  98. package/backend/dist/services/pluginCapabilityRegistryService.d.ts +11 -0
  99. package/backend/dist/services/pluginCapabilityRegistryService.d.ts.map +1 -0
  100. package/backend/dist/services/pluginCapabilityRegistryService.js +70 -0
  101. package/backend/dist/services/pluginCapabilityRegistryService.js.map +1 -0
  102. package/backend/dist/services/pluginCredentialsService.d.ts.map +1 -1
  103. package/backend/dist/services/pluginCredentialsService.js +13 -11
  104. package/backend/dist/services/pluginCredentialsService.js.map +1 -1
  105. package/backend/dist/services/pluginEmbeddingService.d.ts +26 -0
  106. package/backend/dist/services/pluginEmbeddingService.d.ts.map +1 -0
  107. package/backend/dist/services/pluginEmbeddingService.js +159 -0
  108. package/backend/dist/services/pluginEmbeddingService.js.map +1 -0
  109. package/backend/dist/services/pluginImageGenerationService.d.ts +29 -0
  110. package/backend/dist/services/pluginImageGenerationService.d.ts.map +1 -0
  111. package/backend/dist/services/pluginImageGenerationService.js +402 -0
  112. package/backend/dist/services/pluginImageGenerationService.js.map +1 -0
  113. package/backend/dist/services/pluginService.d.ts +21 -17
  114. package/backend/dist/services/pluginService.d.ts.map +1 -1
  115. package/backend/dist/services/pluginService.js +135 -1356
  116. package/backend/dist/services/pluginService.js.map +1 -1
  117. package/backend/dist/services/pluginTTSService.d.ts +28 -0
  118. package/backend/dist/services/pluginTTSService.d.ts.map +1 -0
  119. package/backend/dist/services/pluginTTSService.js +286 -0
  120. package/backend/dist/services/pluginTTSService.js.map +1 -0
  121. package/backend/dist/services/pluginVariablesService.d.ts.map +1 -1
  122. package/backend/dist/services/pluginVariablesService.js +7 -5
  123. package/backend/dist/services/pluginVariablesService.js.map +1 -1
  124. package/backend/dist/services/preferencesService.d.ts +1 -0
  125. package/backend/dist/services/preferencesService.d.ts.map +1 -1
  126. package/backend/dist/services/preferencesService.js +21 -7
  127. package/backend/dist/services/preferencesService.js.map +1 -1
  128. package/backend/dist/services/simpleGitHubOAuth.d.ts.map +1 -1
  129. package/backend/dist/services/simpleGitHubOAuth.js +10 -8
  130. package/backend/dist/services/simpleGitHubOAuth.js.map +1 -1
  131. package/backend/dist/services/simpleHuggingFaceOAuth.d.ts.map +1 -1
  132. package/backend/dist/services/simpleHuggingFaceOAuth.js +11 -9
  133. package/backend/dist/services/simpleHuggingFaceOAuth.js.map +1 -1
  134. package/backend/dist/services/systemSettingsService.d.ts.map +1 -1
  135. package/backend/dist/services/systemSettingsService.js +4 -2
  136. package/backend/dist/services/systemSettingsService.js.map +1 -1
  137. package/backend/dist/services/titleGenerationService.d.ts +55 -0
  138. package/backend/dist/services/titleGenerationService.d.ts.map +1 -0
  139. package/backend/dist/services/titleGenerationService.js +121 -0
  140. package/backend/dist/services/titleGenerationService.js.map +1 -0
  141. package/backend/dist/services/turnstileService.d.ts +13 -0
  142. package/backend/dist/services/turnstileService.d.ts.map +1 -0
  143. package/backend/dist/services/turnstileService.js +79 -0
  144. package/backend/dist/services/turnstileService.js.map +1 -0
  145. package/backend/dist/storage.d.ts +2 -22
  146. package/backend/dist/storage.d.ts.map +1 -1
  147. package/backend/dist/storage.js +22 -170
  148. package/backend/dist/storage.js.map +1 -1
  149. package/backend/dist/storageMappers.d.ts +82 -0
  150. package/backend/dist/storageMappers.d.ts.map +1 -0
  151. package/backend/dist/storageMappers.js +85 -0
  152. package/backend/dist/storageMappers.js.map +1 -0
  153. package/backend/dist/test-encryption.js +20 -18
  154. package/backend/dist/test-encryption.js.map +1 -1
  155. package/backend/dist/types/index.d.ts +6 -0
  156. package/backend/dist/types/index.d.ts.map +1 -1
  157. package/backend/dist/types/index.js.map +1 -1
  158. package/backend/dist/utils/assistantBranching.d.ts +8 -0
  159. package/backend/dist/utils/assistantBranching.d.ts.map +1 -0
  160. package/backend/dist/utils/assistantBranching.js +30 -0
  161. package/backend/dist/utils/assistantBranching.js.map +1 -0
  162. package/backend/dist/utils/chatContext.d.ts +17 -0
  163. package/backend/dist/utils/chatContext.d.ts.map +1 -0
  164. package/backend/dist/utils/chatContext.js +82 -0
  165. package/backend/dist/utils/chatContext.js.map +1 -0
  166. package/backend/dist/utils/chatDocumentContext.d.ts +7 -0
  167. package/backend/dist/utils/chatDocumentContext.d.ts.map +1 -0
  168. package/backend/dist/utils/chatDocumentContext.js +51 -0
  169. package/backend/dist/utils/chatDocumentContext.js.map +1 -0
  170. package/backend/dist/utils/logger.d.ts +12 -0
  171. package/backend/dist/utils/logger.d.ts.map +1 -0
  172. package/backend/dist/utils/logger.js +82 -0
  173. package/backend/dist/utils/logger.js.map +1 -0
  174. package/backend/dist/utils/ollamaLibrary.d.ts +17 -0
  175. package/backend/dist/utils/ollamaLibrary.d.ts.map +1 -0
  176. package/backend/dist/utils/ollamaLibrary.js +253 -0
  177. package/backend/dist/utils/ollamaLibrary.js.map +1 -0
  178. package/backend/dist/utils/ollamaStreaming.d.ts +19 -0
  179. package/backend/dist/utils/ollamaStreaming.d.ts.map +1 -0
  180. package/backend/dist/utils/ollamaStreaming.js +67 -0
  181. package/backend/dist/utils/ollamaStreaming.js.map +1 -0
  182. package/backend/dist/utils/packagePaths.d.ts +10 -0
  183. package/backend/dist/utils/packagePaths.d.ts.map +1 -0
  184. package/backend/dist/utils/packagePaths.js +81 -0
  185. package/backend/dist/utils/packagePaths.js.map +1 -0
  186. package/backend/dist/utils/pluginChatAdapter.d.ts +32 -0
  187. package/backend/dist/utils/pluginChatAdapter.d.ts.map +1 -0
  188. package/backend/dist/utils/pluginChatAdapter.js +308 -0
  189. package/backend/dist/utils/pluginChatAdapter.js.map +1 -0
  190. package/backend/dist/utils/pluginChatContext.d.ts +23 -0
  191. package/backend/dist/utils/pluginChatContext.d.ts.map +1 -0
  192. package/backend/dist/utils/pluginChatContext.js +77 -0
  193. package/backend/dist/utils/pluginChatContext.js.map +1 -0
  194. package/backend/dist/utils/pluginResponse.d.ts +6 -0
  195. package/backend/dist/utils/pluginResponse.d.ts.map +1 -0
  196. package/backend/dist/utils/pluginResponse.js +70 -0
  197. package/backend/dist/utils/pluginResponse.js.map +1 -0
  198. package/backend/dist/utils/pluginStreamAdapter.d.ts +16 -0
  199. package/backend/dist/utils/pluginStreamAdapter.d.ts.map +1 -0
  200. package/backend/dist/utils/pluginStreamAdapter.js +141 -0
  201. package/backend/dist/utils/pluginStreamAdapter.js.map +1 -0
  202. package/backend/dist/utils/pluginStreaming.d.ts +24 -0
  203. package/backend/dist/utils/pluginStreaming.d.ts.map +1 -0
  204. package/backend/dist/utils/pluginStreaming.js +106 -0
  205. package/backend/dist/utils/pluginStreaming.js.map +1 -0
  206. package/backend/dist/utils/pluginUpload.d.ts +8 -0
  207. package/backend/dist/utils/pluginUpload.d.ts.map +1 -0
  208. package/backend/dist/utils/pluginUpload.js +51 -0
  209. package/backend/dist/utils/pluginUpload.js.map +1 -0
  210. package/backend/dist/utils/pluginValidation.d.ts +10 -0
  211. package/backend/dist/utils/pluginValidation.d.ts.map +1 -0
  212. package/backend/dist/utils/pluginValidation.js +94 -0
  213. package/backend/dist/utils/pluginValidation.js.map +1 -0
  214. package/backend/dist/utils/pluginVariableValidation.d.ts +10 -0
  215. package/backend/dist/utils/pluginVariableValidation.d.ts.map +1 -0
  216. package/backend/dist/utils/pluginVariableValidation.js +80 -0
  217. package/backend/dist/utils/pluginVariableValidation.js.map +1 -0
  218. package/backend/dist/utils/websocketMessages.d.ts +26 -0
  219. package/backend/dist/utils/websocketMessages.d.ts.map +1 -0
  220. package/backend/dist/utils/websocketMessages.js +74 -0
  221. package/backend/dist/utils/websocketMessages.js.map +1 -0
  222. package/backend/dist/websocketServer.d.ts +3 -0
  223. package/backend/dist/websocketServer.d.ts.map +1 -0
  224. package/backend/dist/websocketServer.js +332 -0
  225. package/backend/dist/websocketServer.js.map +1 -0
  226. package/backend/package.json +55 -0
  227. package/frontend/dist/assets/index-BxHDVIti.css +1 -0
  228. package/frontend/dist/assets/index-CDA2f43l.js +5 -0
  229. package/frontend/dist/assets/markdown-math-CYlHEDRF.css +1 -0
  230. package/frontend/dist/index.html +12 -15
  231. package/frontend/dist/js/ArtifactContainer-CA523BOS.js +1 -0
  232. package/frontend/dist/js/ArtifactDemoPage-C0p2-ccP.js +200 -0
  233. package/frontend/dist/js/ArtifactSlideOutPanel-BBYuYyr_.js +1 -0
  234. package/frontend/dist/js/Button-BmZQkAPD.js +1 -0
  235. package/frontend/dist/js/ChatPage-QSOhIynr.js +16 -0
  236. package/frontend/dist/js/FirstTimeSetup-pi-S4VU-.js +1 -0
  237. package/frontend/dist/js/GalleryPage-BPXB-D4f.js +1 -0
  238. package/frontend/dist/js/ModelManagerModals-D3SZV7CU.js +1 -0
  239. package/frontend/dist/js/ModelsPage-Fmqc1Zxb.js +2 -0
  240. package/frontend/dist/js/OptimizedSyntaxHighlighter-DlI4omE2.js +1 -0
  241. package/frontend/dist/js/PersonasPage-BuLOCG1Y.js +13 -0
  242. package/frontend/dist/js/RichMessageContent-Darvuw8F.js +3 -0
  243. package/frontend/dist/js/SettingsModal-CQhAeaia.js +1 -0
  244. package/frontend/dist/js/UserManagementPage-yX9vOase.js +1 -0
  245. package/frontend/dist/js/api-d05r9G59.js +15 -0
  246. package/frontend/dist/js/ar-BIL8U_dv.js +2 -0
  247. package/frontend/dist/js/artifactHtml-CRdkOCnz.js +27 -0
  248. package/frontend/dist/js/bn-BWQoWPn6.js +2 -0
  249. package/frontend/dist/js/cs-DOD1vscE.js +2 -0
  250. package/frontend/dist/js/da-DYPzSzX4.js +2 -0
  251. package/frontend/dist/js/de-CHd4b2Xf.js +2 -0
  252. package/frontend/dist/js/demoMode-BhEpMVh5.js +1 -0
  253. package/frontend/dist/js/es-BmDO6uNe.js +2 -0
  254. package/frontend/dist/js/fr-DW24sUV7.js +2 -0
  255. package/frontend/dist/js/hi-BRDq_y1B.js +2 -0
  256. package/frontend/dist/js/id-I_F2nstV.js +2 -0
  257. package/frontend/dist/js/is-Boj7-J12.js +2 -0
  258. package/frontend/dist/js/it-CNeHl9Fh.js +2 -0
  259. package/frontend/dist/js/ja-DrVG6G8s.js +2 -0
  260. package/frontend/dist/js/ko-BtKRwlHW.js +2 -0
  261. package/frontend/dist/js/logger-1THq8-96.js +1 -0
  262. package/frontend/dist/js/markdown-core-CEpaJrKd.js +16 -0
  263. package/frontend/dist/js/markdown-math-DAmpvM9y.js +261 -0
  264. package/frontend/dist/js/ms-Dp9MigoU.js +2 -0
  265. package/frontend/dist/js/nl-Bk9pGoCF.js +2 -0
  266. package/frontend/dist/js/pl-seE8_lLS.js +2 -0
  267. package/frontend/dist/js/pt-BAeBtldI.js +2 -0
  268. package/frontend/dist/js/{react-vendor-BUzyfgnZ.js → react-vendor-DtQrJICq.js} +4 -4
  269. package/frontend/dist/js/rolldown-runtime-QTnfLwEv.js +1 -0
  270. package/frontend/dist/js/ru-CkCpWFNb.js +2 -0
  271. package/frontend/dist/js/sv-BM7nqxmA.js +2 -0
  272. package/frontend/dist/js/syntax-highlight-B758xPeo.js +8 -0
  273. package/frontend/dist/js/th-vDIZbpa4.js +2 -0
  274. package/frontend/dist/js/tr-G2-XodhW.js +2 -0
  275. package/frontend/dist/js/ui-vendor-MCs1MDmN.js +177 -0
  276. package/frontend/dist/js/uk-DfVfZcBQ.js +2 -0
  277. package/frontend/dist/js/useBaseQuery-CrdrLcmR.js +1 -0
  278. package/frontend/dist/js/useQuery-DaEnYfWR.js +1 -0
  279. package/frontend/dist/js/utils-es0n-47H.js +1 -0
  280. package/frontend/dist/js/utils-vendor-B2ZBL57Y.js +9 -0
  281. package/frontend/dist/js/vi-Yp9yjb06.js +2 -0
  282. package/frontend/dist/js/zh-DtRb97we.js +2 -0
  283. package/package.json +16 -10
  284. package/plugins/llama-cpp.json +93 -0
  285. package/plugins/openclaw-agent.json +1 -1
  286. package/backend/dist/services/openclawSessionService.d.ts +0 -63
  287. package/backend/dist/services/openclawSessionService.d.ts.map +0 -1
  288. package/backend/dist/services/openclawSessionService.js +0 -307
  289. package/backend/dist/services/openclawSessionService.js.map +0 -1
  290. package/frontend/dist/assets/index-B1qNw5pA.js +0 -52
  291. package/frontend/dist/assets/index-DnG6512Q.css +0 -1
  292. package/frontend/dist/assets/ui-CsNe6lFV.css +0 -1
  293. package/frontend/dist/js/ArtifactContainer-Com7P5vK.js +0 -24
  294. package/frontend/dist/js/ArtifactDemoPage-Xd-Ragpf.js +0 -98
  295. package/frontend/dist/js/Button-D62PCbMW.js +0 -1
  296. package/frontend/dist/js/ChatPage-01JxVHRP.js +0 -15
  297. package/frontend/dist/js/GalleryPage-8nHGqKEu.js +0 -1
  298. package/frontend/dist/js/ModelsPage-C0MLVfwA.js +0 -1
  299. package/frontend/dist/js/OptimizedSyntaxHighlighter-DjnSqskO.js +0 -1
  300. package/frontend/dist/js/PersonasPage-D9YKnYz6.js +0 -13
  301. package/frontend/dist/js/UserManagementPage-b6NZdPTD.js +0 -1
  302. package/frontend/dist/js/api-B0pf9vdR.js +0 -1
  303. package/frontend/dist/js/api-BrzQvj_g.js +0 -3
  304. package/frontend/dist/js/appStore-k_e3JAM3.js +0 -2
  305. package/frontend/dist/js/authStore-BkobbhZS.js +0 -1
  306. package/frontend/dist/js/authStore-ge66S03D.js +0 -2
  307. package/frontend/dist/js/axios-CYrHjdt1.js +0 -1
  308. package/frontend/dist/js/demoMode-qyEGd_UM.js +0 -1
  309. package/frontend/dist/js/markdown-vendor-4lCPqxk5.js +0 -22
  310. package/frontend/dist/js/rolldown-runtime-DWy-cpC1.js +0 -1
  311. package/frontend/dist/js/ui-DuH1cD2f.js +0 -267
  312. package/frontend/dist/js/ui-vendor-BFOABH-A.js +0 -177
  313. package/frontend/dist/js/userService-BndgbS1C.js +0 -1
  314. package/frontend/dist/js/utils-vendor-MBozwMfr.js +0 -6
@@ -20,12 +20,45 @@ import sanitize from 'sanitize-filename';
20
20
  import axios from 'axios';
21
21
  import pluginCredentialsService from './pluginCredentialsService.js';
22
22
  import pluginVariablesService from './pluginVariablesService.js';
23
+ import { PluginCapabilityRegistryService } from './pluginCapabilityRegistryService.js';
24
+ import { PluginEmbeddingService } from './pluginEmbeddingService.js';
25
+ import { PluginImageGenerationService } from './pluginImageGenerationService.js';
26
+ import { PluginTTSService } from './pluginTTSService.js';
27
+ import { buildPluginChatPayload, convertProviderResponse, resolvePluginChatParameters, toOpenAICompatibleMessages, } from '../utils/pluginChatAdapter.js';
28
+ import { streamOpenAICompatibleResponse, } from '../utils/pluginStreamAdapter.js';
29
+ import { addOpenClawSessionHeader, applyModelEndpointTemplate, assertSafePluginEndpoint, buildPluginAuthHeaders, resolvePluginEndpoint, validatePluginEndpointOverride, validatePluginModel, } from '../utils/pluginValidation.js';
30
+ import { createLogger } from '../utils/logger.js';
31
+ const logger = createLogger('plugins');
23
32
  class PluginService {
24
33
  constructor() {
25
34
  this.activePluginIds = new Set();
26
35
  this.pluginsDir = path.join(process.cwd(), 'plugins');
27
36
  this.ensurePluginsDirectory();
28
37
  this.loadActivePlugins();
38
+ this.embeddingService = new PluginEmbeddingService({
39
+ getAllPlugins: () => this.getAllPlugins(),
40
+ getApiKey: (plugin, userId) => this.getApiKey(plugin, userId),
41
+ getPluginVariables: (plugin, userId) => this.getPluginVariables(plugin, userId),
42
+ validateEndpointUrl: endpoint => this.validateEndpointUrl(endpoint),
43
+ });
44
+ this.ttsService = new PluginTTSService({
45
+ getAllPlugins: () => this.getAllPlugins(),
46
+ getPlugin: id => this.getPlugin(id),
47
+ getApiKey: (plugin, userId) => this.getApiKey(plugin, userId),
48
+ getPluginVariables: (plugin, userId) => this.getPluginVariables(plugin, userId),
49
+ validateEndpointUrl: endpoint => this.validateEndpointUrl(endpoint),
50
+ });
51
+ this.imageGenerationService = new PluginImageGenerationService({
52
+ getAllPlugins: () => this.getAllPlugins(),
53
+ getPlugin: id => this.getPlugin(id),
54
+ getApiKey: (plugin, userId) => this.getApiKey(plugin, userId),
55
+ getPluginVariables: (plugin, userId) => this.getPluginVariables(plugin, userId),
56
+ validateEndpointUrl: endpoint => this.validateEndpointUrl(endpoint),
57
+ });
58
+ this.capabilityRegistryService = new PluginCapabilityRegistryService({
59
+ getAllPlugins: () => this.getAllPlugins(),
60
+ getApiKey: (plugin, userId) => this.getApiKey(plugin, userId),
61
+ });
29
62
  }
30
63
  ensurePluginsDirectory() {
31
64
  if (!fs.existsSync(this.pluginsDir)) {
@@ -46,7 +79,7 @@ class PluginService {
46
79
  }
47
80
  }
48
81
  catch (error) {
49
- console.error('Failed to load plugin status:', error);
82
+ logger.error('Failed to load plugin status:', error);
50
83
  }
51
84
  }
52
85
  }
@@ -81,40 +114,48 @@ class PluginService {
81
114
  * Returns the URL string if valid, or null if invalid.
82
115
  */
83
116
  validateEndpointUrl(endpoint) {
84
- try {
85
- const url = new URL(endpoint);
86
- const isLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
87
- const isPrivateNetwork = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(url.hostname);
88
- if (url.protocol !== 'https:' && !isLocalhost && !isPrivateNetwork) {
89
- console.warn(`Rejected insecure endpoint override: ${endpoint} (only HTTPS or localhost/private IPs allowed)`);
90
- return null;
91
- }
92
- return endpoint;
93
- }
94
- catch {
95
- console.warn(`Rejected invalid endpoint override URL: ${endpoint}`);
96
- return null;
97
- }
117
+ return validatePluginEndpointOverride(endpoint);
118
+ }
119
+ getModelsEndpoint(endpoint) {
120
+ const url = new URL(endpoint);
121
+ url.search = '';
122
+ if (url.pathname.endsWith('/models')) {
123
+ return url.toString();
124
+ }
125
+ if (url.pathname.endsWith('/chat/completions')) {
126
+ url.pathname = `${url.pathname.slice(0, -'/chat/completions'.length)}/models`;
127
+ return url.toString();
128
+ }
129
+ if (url.pathname.endsWith('/completions')) {
130
+ url.pathname = `${url.pathname.slice(0, -'/completions'.length)}/models`;
131
+ return url.toString();
132
+ }
133
+ if (url.pathname.endsWith('/embeddings')) {
134
+ url.pathname = `${url.pathname.slice(0, -'/embeddings'.length)}/models`;
135
+ return url.toString();
136
+ }
137
+ const basePath = url.pathname === '/'
138
+ ? ''
139
+ : url.pathname.endsWith('/')
140
+ ? url.pathname.slice(0, -1)
141
+ : url.pathname;
142
+ url.pathname = `${basePath}/models`;
143
+ return url.toString();
98
144
  }
99
145
  /**
100
146
  * Attempt to auto-discover available models from a plugin's base endpoint.
101
147
  * Hits {baseUrl}/v1/models (OpenAI-compatible) and updates the plugin's model_map.
102
148
  * Falls back silently to the existing model_map if the endpoint is unavailable.
103
149
  */
104
- async discoverModels(pluginId) {
150
+ async discoverModels(pluginId, userId) {
105
151
  const plugin = this.getPlugin(pluginId);
106
152
  if (!plugin)
107
153
  return [];
108
- // Derive base URL from endpoint (strip /v1/chat/completions etc.)
109
- let baseUrl;
110
- try {
111
- const url = new URL(plugin.endpoint);
112
- baseUrl = `${url.protocol}//${url.host}`;
113
- }
114
- catch {
115
- return plugin.model_map;
116
- }
117
- const apiKey = this.getApiKey(plugin);
154
+ const pluginVars = this.getPluginVariables(plugin, userId);
155
+ const endpointOverride = pluginVars.endpoint;
156
+ const effectiveEndpoint = (endpointOverride && this.validateEndpointUrl(endpointOverride)) ||
157
+ plugin.endpoint;
158
+ const apiKey = this.getApiKey(plugin, userId);
118
159
  const headers = {
119
160
  Accept: 'application/json',
120
161
  };
@@ -125,7 +166,7 @@ class PluginService {
125
166
  headers[plugin.auth.header] = authValue;
126
167
  }
127
168
  try {
128
- const response = await axios.get(`${baseUrl}/v1/models`, {
169
+ const response = await axios.get(this.getModelsEndpoint(effectiveEndpoint), {
129
170
  headers,
130
171
  timeout: 5000,
131
172
  });
@@ -134,7 +175,7 @@ class PluginService {
134
175
  .map((m) => m.id)
135
176
  .filter((id) => typeof id === 'string');
136
177
  if (models.length > 0) {
137
- console.log('[Plugin] Auto-discovered %d models for %s:', models.length, pluginId, models);
178
+ logger.debug('[Plugin] Auto-discovered %d models for %s:', models.length, pluginId, models);
138
179
  // Update the plugin file with discovered models
139
180
  plugin.model_map = models;
140
181
  const safeId = pluginId.replace(/[^a-zA-Z0-9_.-]/g, '');
@@ -155,7 +196,7 @@ class PluginService {
155
196
  }
156
197
  }
157
198
  catch (_error) {
158
- console.log(`[Plugin] Model discovery unavailable for ${pluginId}, using existing model_map`);
199
+ logger.debug(`[Plugin] Model discovery unavailable for ${pluginId}, using existing model_map`);
159
200
  }
160
201
  return plugin.model_map;
161
202
  }
@@ -177,13 +218,13 @@ class PluginService {
177
218
  }
178
219
  }
179
220
  catch (error) {
180
- console.error(`Failed to load plugin ${file}:`, error);
221
+ logger.error(`Failed to load plugin ${file}:`, error);
181
222
  }
182
223
  }
183
224
  }
184
225
  }
185
226
  catch (error) {
186
- console.error('Failed to read plugins directory:', error);
227
+ logger.error('Failed to read plugins directory:', error);
187
228
  }
188
229
  return plugins;
189
230
  }
@@ -192,7 +233,7 @@ class PluginService {
192
233
  // Sanitize the ID to prevent path traversal
193
234
  const sanitizedId = sanitize(id);
194
235
  if (!sanitizedId || sanitizedId !== id) {
195
- console.error('Invalid plugin ID provided:', id);
236
+ logger.error('Invalid plugin ID provided:', id);
196
237
  return null;
197
238
  }
198
239
  const filePath = path.resolve(this.pluginsDir, `${sanitizedId}.json`);
@@ -210,7 +251,7 @@ class PluginService {
210
251
  }
211
252
  }
212
253
  catch (error) {
213
- console.error('Failed to load plugin %s:', sanitizedId, error);
254
+ logger.error('Failed to load plugin %s:', sanitizedId, error);
214
255
  }
215
256
  return null;
216
257
  }
@@ -238,27 +279,23 @@ class PluginService {
238
279
  }
239
280
  // Delete a plugin
240
281
  deletePlugin(id) {
241
- console.log('[Plugin Delete] Attempting to delete plugin:', id);
242
- console.log('[Plugin Delete] Plugins directory:', this.pluginsDir);
243
282
  // Validate the ID parameter using a strict pattern (allows dots for version numbers like 1.6b)
244
283
  const idPattern = /^[a-zA-Z0-9._-]+$/;
245
284
  if (!idPattern.test(id)) {
246
- console.error('[Plugin Delete] Invalid plugin ID format:', id);
285
+ logger.error('Invalid plugin ID format:', id);
247
286
  return false;
248
287
  }
249
288
  // Sanitize the ID to prevent path traversal
250
289
  const sanitizedId = sanitize(id);
251
290
  if (!sanitizedId || sanitizedId !== id) {
252
- console.error('[Plugin Delete] Plugin ID failed sanitization:', id);
291
+ logger.error('Plugin ID failed sanitization:', id);
253
292
  return false;
254
293
  }
255
294
  const filePath = path.resolve(this.pluginsDir, `${sanitizedId}.json`);
256
- console.log('[Plugin Delete] Resolved file path:', filePath);
257
- console.log('[Plugin Delete] File exists:', fs.existsSync(filePath));
258
295
  // Ensure the file path is within the plugins directory
259
296
  if (!filePath.startsWith(path.resolve(this.pluginsDir)) ||
260
297
  !fs.existsSync(filePath)) {
261
- console.error('[Plugin Delete] File path is invalid or does not exist:', filePath);
298
+ logger.error('File path is invalid or does not exist:', filePath);
262
299
  return false;
263
300
  }
264
301
  try {
@@ -273,7 +310,7 @@ class PluginService {
273
310
  return true;
274
311
  }
275
312
  catch (error) {
276
- console.error('Failed to delete plugin %s:', sanitizedId, error);
313
+ logger.error('Failed to delete plugin %s:', sanitizedId, error);
277
314
  return false;
278
315
  }
279
316
  }
@@ -302,35 +339,26 @@ class PluginService {
302
339
  return true;
303
340
  }
304
341
  // Get the active plugin for a specific model
305
- getActivePluginForModel(model) {
306
- console.log(`[DEBUG] Looking for plugin for model: ${model}`);
307
- // Get all available plugins (not just active ones)
308
- const allPlugins = this.getAllPlugins();
309
- console.log(`[DEBUG] Available plugins:`, allPlugins.map(p => p.id));
310
- // Find the plugin that supports this model
311
- for (const plugin of allPlugins) {
312
- console.log(`[DEBUG] Checking plugin ${plugin.id} with model_map:`, plugin.model_map);
342
+ getActivePluginForModel(model, userId) {
343
+ // Only route through plugins the user explicitly activated.
344
+ const activePlugins = this.getActivePlugins();
345
+ // Find the active plugin that supports this model
346
+ for (const plugin of activePlugins) {
313
347
  if (plugin.model_map.includes(model)) {
314
- console.log(`[DEBUG] Found plugin ${plugin.id} for model ${model}`);
315
348
  // Check if we have the required API key (from DB or env)
316
- const apiKey = this.getApiKey(plugin);
349
+ const apiKey = this.getApiKey(plugin, userId);
317
350
  if (!apiKey) {
318
- console.log(`[DEBUG] Plugin ${plugin.id} found but API key not set (checked DB and ${plugin.auth.key_env})`);
319
351
  continue;
320
352
  }
321
353
  return plugin;
322
354
  }
323
355
  }
324
- console.log(`[DEBUG] No plugin found for model: ${model}`);
325
356
  return null;
326
357
  }
327
358
  // Get all currently active plugins
328
359
  getActivePlugins() {
329
360
  const allPlugins = this.getAllPlugins();
330
- console.log('All plugins:', allPlugins.map(p => ({ id: p.id, active: p.active })));
331
- console.log('Active plugin IDs in memory:', Array.from(this.activePluginIds));
332
361
  const activePlugins = allPlugins.filter(plugin => this.activePluginIds.has(plugin.id));
333
- console.log('Filtered active plugins:', activePlugins.map(p => p.id));
334
362
  return activePlugins;
335
363
  }
336
364
  // Legacy method for backward compatibility - returns first active plugin
@@ -348,248 +376,36 @@ class PluginService {
348
376
  }));
349
377
  }
350
378
  // Execute a chat request through the active plugin
351
- async executePluginRequest(model, messages, options = {}) {
352
- // Validate model parameter to prevent SSRF attacks
353
- if (!model || typeof model !== 'string') {
354
- throw new Error('Invalid model parameter: must be a non-empty string');
355
- }
356
- // Sanitize model parameter - only allow alphanumeric, hyphens, underscores, colons, dots, and slashes (for provider/model format)
357
- const modelPattern = /^[a-zA-Z0-9\-_:./]+$/;
358
- if (!modelPattern.test(model)) {
359
- throw new Error(`Invalid model parameter: ${model} contains invalid characters`);
360
- }
361
- // Prevent path traversal and other malicious patterns
362
- if (model.includes('..') || model.includes('\\')) {
363
- throw new Error(`Invalid model parameter: ${model} contains invalid patterns`);
364
- }
365
- const activePlugin = this.getActivePluginForModel(model);
379
+ async executePluginRequest(model, messages, options = {}, userId) {
380
+ validatePluginModel(model);
381
+ const activePlugin = this.getActivePluginForModel(model, userId);
366
382
  if (!activePlugin) {
367
383
  throw new Error(`No active plugin found for model: ${model}`);
368
384
  }
369
- // Additional validation: ensure the model is in the plugin's allowed model_map
370
385
  if (!activePlugin.model_map.includes(model)) {
371
386
  throw new Error(`Model ${model} is not supported by plugin ${activePlugin.id}`);
372
387
  }
373
- if (!activePlugin) {
374
- throw new Error(`No active plugin found for model: ${model}`);
375
- }
376
- // Get API key from database (per-user) or environment variable (fallback)
377
- const apiKey = this.getApiKey(activePlugin);
388
+ const apiKey = this.getApiKey(activePlugin, userId);
378
389
  if (!apiKey) {
379
390
  throw new Error(`API key not found for plugin ${activePlugin.id} (set via Settings or ${activePlugin.auth.key_env} env var)`);
380
391
  }
381
- // Load plugin variables (valves) - these serve as defaults that options can override
382
- const pluginVars = this.getPluginVariables(activePlugin);
383
- // Allow endpoint override via plugin variables
384
- const endpointOverride = pluginVars.endpoint;
385
- const effectiveEndpoint = (endpointOverride && this.validateEndpointUrl(endpointOverride)) ||
386
- activePlugin.endpoint;
387
- const temperature = options.temperature ??
388
- pluginVars.temperature ??
389
- 0.7;
390
- const maxTokens = options.num_predict === -1
391
- ? undefined
392
- : (options.num_predict ??
393
- pluginVars.max_tokens ??
394
- undefined);
395
- const topP = options.top_p ?? pluginVars.top_p ?? undefined;
396
- const frequencyPenalty = pluginVars.frequency_penalty ?? undefined;
397
- const presencePenalty = pluginVars.presence_penalty ?? undefined;
398
- const shouldStream = pluginVars.stream ?? false;
399
- // Prepare headers
400
- const headers = {
401
- 'Content-Type': 'application/json',
402
- };
403
- const authValue = activePlugin.auth.prefix
404
- ? `${activePlugin.auth.prefix}${apiKey}`
405
- : apiKey;
406
- headers[activePlugin.auth.header] = authValue;
407
- // OpenClaw session routing — send through the main agent session for full tool access
408
- if (activePlugin.id === 'openclaw-agent') {
409
- const sessionKey = pluginVars.session_key || 'main';
410
- const sessionMode = pluginVars.session_mode;
411
- if (sessionMode !== false) {
412
- headers['x-openclaw-session-key'] = sessionKey;
413
- }
414
- }
415
- // Prepare request payload based on plugin type
416
- let payload;
417
- if (activePlugin.id === 'anthropic') {
418
- // Anthropic-specific payload format
419
- // Separate system messages from user/assistant messages
420
- const systemMessages = messages.filter(msg => msg.role === 'system');
421
- const nonSystemMessages = messages.filter(msg => msg.role !== 'system');
422
- // Convert messages to Anthropic format with image support
423
- const anthropicMessages = nonSystemMessages.map(msg => {
424
- // Check if message has images
425
- if (msg.images && msg.images.length > 0) {
426
- // Anthropic format: content is an array of content blocks
427
- const contentBlocks = [];
428
- // Add images first
429
- for (const image of msg.images) {
430
- // Extract base64 data and media type from data URL
431
- let base64Data = image;
432
- let mediaType = 'image/jpeg'; // Default
433
- if (image.startsWith('data:')) {
434
- const match = image.match(/^data:([^;]+);base64,(.+)$/);
435
- if (match) {
436
- mediaType = match[1];
437
- base64Data = match[2];
438
- }
439
- }
440
- contentBlocks.push({
441
- type: 'image',
442
- source: {
443
- type: 'base64',
444
- media_type: mediaType,
445
- data: base64Data,
446
- },
447
- });
448
- }
449
- // Add text content
450
- if (msg.content) {
451
- contentBlocks.push({
452
- type: 'text',
453
- text: msg.content,
454
- });
455
- }
456
- return {
457
- role: msg.role,
458
- content: contentBlocks,
459
- };
460
- }
461
- // No images - simple text content
462
- return {
463
- role: msg.role,
464
- content: msg.content,
465
- };
466
- });
467
- payload = {
468
- model,
469
- messages: anthropicMessages,
470
- max_tokens: maxTokens ?? 1024,
471
- temperature,
472
- top_p: topP,
473
- stop_sequences: options.stop,
474
- stream: shouldStream,
475
- };
476
- // Add system message as top-level parameter if present
477
- if (systemMessages.length > 0) {
478
- payload.system = systemMessages.map(msg => msg.content).join('\n');
479
- }
480
- // Add required anthropic-version header
481
- headers['anthropic-version'] = '2023-06-01';
482
- }
483
- else if (activePlugin.id === 'gemini') {
484
- // Gemini-specific payload format with image support
485
- const lastMessage = messages[messages.length - 1];
486
- const parts = [];
487
- // Add images if present
488
- if (lastMessage?.images && lastMessage.images.length > 0) {
489
- for (const image of lastMessage.images) {
490
- let base64Data = image;
491
- let mimeType = 'image/jpeg';
492
- if (image.startsWith('data:')) {
493
- const match = image.match(/^data:([^;]+);base64,(.+)$/);
494
- if (match) {
495
- mimeType = match[1];
496
- base64Data = match[2];
497
- }
498
- }
499
- parts.push({
500
- inline_data: {
501
- mime_type: mimeType,
502
- data: base64Data,
503
- },
504
- });
505
- }
506
- }
507
- // Add text content
508
- if (lastMessage?.content) {
509
- parts.push({ text: lastMessage.content });
510
- }
511
- payload = {
512
- contents: [{ parts }],
513
- generationConfig: {
514
- temperature,
515
- maxOutputTokens: maxTokens ?? 1024,
516
- topP: topP,
517
- stopSequences: options.stop,
518
- },
519
- };
520
- }
521
- else {
522
- // Default OpenAI-compatible format with image support
523
- const openaiMessages = messages.map(msg => {
524
- // Check if message has images (OpenAI vision format)
525
- if (msg.images && msg.images.length > 0) {
526
- const content = [];
527
- // Add images
528
- for (const image of msg.images) {
529
- // OpenAI expects data URLs or regular URLs
530
- const imageUrl = image.startsWith('data:')
531
- ? image
532
- : `data:image/jpeg;base64,${image}`;
533
- content.push({
534
- type: 'image_url',
535
- image_url: { url: imageUrl },
536
- });
537
- }
538
- // Add text
539
- if (msg.content) {
540
- content.push({ type: 'text', text: msg.content });
541
- }
542
- return { role: msg.role, content };
543
- }
544
- return { role: msg.role, content: msg.content };
545
- });
546
- payload = {
547
- model,
548
- messages: openaiMessages,
549
- temperature,
550
- max_tokens: maxTokens,
551
- top_p: topP,
552
- frequency_penalty: frequencyPenalty,
553
- presence_penalty: presencePenalty,
554
- stop: options.stop,
555
- stream: shouldStream,
556
- };
557
- }
558
- // Process endpoint template - replace {model} with actual model name
559
- // Final validation before URL construction to prevent SSRF
560
- const sanitizedModel = encodeURIComponent(model);
561
- const processedEndpoint = effectiveEndpoint.replace('{model}', sanitizedModel);
562
- // Validate the final endpoint URL
563
- try {
564
- const url = new URL(processedEndpoint);
565
- // Allow HTTP for localhost and private network IPs (safe for local/LAN development)
566
- const isLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
567
- const isPrivateNetwork = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(url.hostname);
568
- if (url.protocol !== 'https:' && !isLocalhost && !isPrivateNetwork) {
569
- throw new Error(`Insecure endpoint protocol: ${url.protocol}. Only HTTPS is allowed for remote endpoints. ` +
570
- `(HTTP is permitted for localhost and private network IPs)`);
571
- }
572
- }
573
- catch (_error) {
574
- throw new Error(`Invalid endpoint URL constructed: ${processedEndpoint}`);
575
- }
392
+ const pluginVars = this.getPluginVariables(activePlugin, userId);
393
+ const effectiveEndpoint = resolvePluginEndpoint(activePlugin.endpoint, pluginVars.endpoint);
394
+ const headers = buildPluginAuthHeaders(activePlugin, apiKey);
395
+ addOpenClawSessionHeader(activePlugin, pluginVars, headers);
396
+ const { payload, headers: payloadHeaders } = buildPluginChatPayload(activePlugin, model, messages, options, pluginVars);
397
+ Object.assign(headers, payloadHeaders);
398
+ const processedEndpoint = applyModelEndpointTemplate(effectiveEndpoint, model);
399
+ assertSafePluginEndpoint(processedEndpoint, 'endpoint URL constructed');
576
400
  try {
577
401
  const response = await axios.post(processedEndpoint, payload, {
578
402
  headers,
579
403
  timeout: 60000, // 60 second timeout
580
404
  });
581
- // Handle different response formats
582
- if (activePlugin.id === 'anthropic') {
583
- return this.convertAnthropicResponse(response.data, model);
584
- }
585
- else if (activePlugin.id === 'gemini') {
586
- return this.convertGeminiResponse(response.data, model);
587
- }
588
- // Default to OpenAI format
589
- return response.data;
405
+ return convertProviderResponse(activePlugin, response.data, model);
590
406
  }
591
407
  catch (error) {
592
- console.error(`Plugin request failed for ${activePlugin.id}:`, error);
408
+ logger.error(`Plugin request failed for ${activePlugin.id}:`, error);
593
409
  if (error && typeof error === 'object' && 'response' in error) {
594
410
  const axiosError = error;
595
411
  throw new Error(`Plugin API error: ${axiosError.response.status} - ${axiosError.response.data?.error?.message || axiosError.response.statusText}`);
@@ -608,309 +424,43 @@ class PluginService {
608
424
  * Returns an async generator that yields SSE delta content strings.
609
425
  * Also collects tool_calls from the stream.
610
426
  */
611
- async *executePluginStreamRequest(model, messages, options = {}) {
612
- // Validate model parameter
613
- if (!model || typeof model !== 'string') {
614
- throw new Error('Invalid model parameter: must be a non-empty string');
615
- }
616
- const modelPattern = /^[a-zA-Z0-9\-_:./]+$/;
617
- if (!modelPattern.test(model) || model.includes('..')) {
618
- throw new Error(`Invalid model parameter: ${model}`);
619
- }
620
- const activePlugin = this.getActivePluginForModel(model);
427
+ async *executePluginStreamRequest(model, messages, options = {}, userId) {
428
+ validatePluginModel(model);
429
+ const activePlugin = this.getActivePluginForModel(model, userId);
621
430
  if (!activePlugin) {
622
431
  throw new Error(`No active plugin found for model: ${model}`);
623
432
  }
624
433
  if (!activePlugin.model_map.includes(model)) {
625
434
  throw new Error(`Model ${model} is not supported by plugin ${activePlugin.id}`);
626
435
  }
627
- const apiKey = this.getApiKey(activePlugin);
436
+ const apiKey = this.getApiKey(activePlugin, userId);
628
437
  if (!apiKey) {
629
438
  throw new Error(`API key not found for plugin ${activePlugin.id} (set via Settings or ${activePlugin.auth.key_env} env var)`);
630
439
  }
631
- const pluginVars = this.getPluginVariables(activePlugin);
632
- const endpointOverride = pluginVars.endpoint;
633
- const effectiveEndpoint = (endpointOverride && this.validateEndpointUrl(endpointOverride)) ||
634
- activePlugin.endpoint;
635
- const temperature = options.temperature ??
636
- pluginVars.temperature ??
637
- 0.7;
638
- const maxTokens = options.num_predict === -1
639
- ? undefined
640
- : (options.num_predict ??
641
- pluginVars.max_tokens ??
642
- undefined);
643
- const topP = options.top_p ?? pluginVars.top_p ?? undefined;
644
- const frequencyPenalty = pluginVars.frequency_penalty ?? undefined;
645
- const presencePenalty = pluginVars.presence_penalty ?? undefined;
646
- const headers = {
647
- 'Content-Type': 'application/json',
648
- };
649
- const authValue = activePlugin.auth.prefix
650
- ? `${activePlugin.auth.prefix}${apiKey}`
651
- : apiKey;
652
- headers[activePlugin.auth.header] = authValue;
653
- // OpenClaw session routing — send through the main agent session for full tool access
654
- if (activePlugin.id === 'openclaw-agent') {
655
- const sessionKey = pluginVars.session_key || 'main';
656
- const sessionMode = pluginVars.session_mode;
657
- if (sessionMode !== false) {
658
- headers['x-openclaw-session-key'] = sessionKey;
659
- }
660
- }
661
- // Build OpenAI-compatible payload with stream=true
662
- const openaiMessages = messages.map(msg => {
663
- if (msg.images && msg.images.length > 0) {
664
- const content = [];
665
- for (const image of msg.images) {
666
- const imageUrl = image.startsWith('data:')
667
- ? image
668
- : `data:image/jpeg;base64,${image}`;
669
- content.push({ type: 'image_url', image_url: { url: imageUrl } });
670
- }
671
- if (msg.content) {
672
- content.push({ type: 'text', text: msg.content });
673
- }
674
- return { role: msg.role, content };
675
- }
676
- return { role: msg.role, content: msg.content };
677
- });
440
+ const pluginVars = this.getPluginVariables(activePlugin, userId);
441
+ const effectiveEndpoint = resolvePluginEndpoint(activePlugin.endpoint, pluginVars.endpoint);
442
+ const params = resolvePluginChatParameters(options, pluginVars);
443
+ const headers = buildPluginAuthHeaders(activePlugin, apiKey);
444
+ addOpenClawSessionHeader(activePlugin, pluginVars, headers);
678
445
  const payload = {
679
446
  model,
680
- messages: openaiMessages,
681
- temperature,
682
- max_tokens: maxTokens,
683
- top_p: topP,
684
- frequency_penalty: frequencyPenalty,
685
- presence_penalty: presencePenalty,
447
+ messages: toOpenAICompatibleMessages(messages),
448
+ temperature: params.temperature,
449
+ max_tokens: params.maxTokens,
450
+ top_p: params.topP,
451
+ frequency_penalty: params.frequencyPenalty,
452
+ presence_penalty: params.presencePenalty,
686
453
  stop: options.stop,
687
454
  stream: true,
688
455
  };
689
- const sanitizedModel = encodeURIComponent(model);
690
- const processedEndpoint = effectiveEndpoint.replace('{model}', sanitizedModel);
691
- // Validate URL
692
- try {
693
- const url = new URL(processedEndpoint);
694
- const isLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
695
- const isPrivateNetwork = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(url.hostname);
696
- if (url.protocol !== 'https:' && !isLocalhost && !isPrivateNetwork) {
697
- throw new Error(`Insecure endpoint protocol: ${url.protocol}`);
698
- }
699
- }
700
- catch {
701
- throw new Error(`Invalid endpoint URL: ${processedEndpoint}`);
702
- }
703
- // Use native fetch for streaming
456
+ const processedEndpoint = applyModelEndpointTemplate(effectiveEndpoint, model);
457
+ assertSafePluginEndpoint(processedEndpoint);
704
458
  const response = await fetch(processedEndpoint, {
705
459
  method: 'POST',
706
460
  headers,
707
461
  body: JSON.stringify(payload),
708
462
  });
709
- if (!response.ok) {
710
- const errorText = await response.text();
711
- throw new Error(`Plugin API error: ${response.status} - ${errorText.slice(0, 200)}`);
712
- }
713
- if (!response.body) {
714
- throw new Error('No response body for streaming');
715
- }
716
- const reader = response.body.getReader();
717
- const decoder = new TextDecoder();
718
- let buffer = '';
719
- // Track tool_calls being built across chunks
720
- const toolCallsInProgress = new Map();
721
- try {
722
- while (true) {
723
- const { done, value } = await reader.read();
724
- if (done)
725
- break;
726
- buffer += decoder.decode(value, { stream: true });
727
- const lines = buffer.split('\n');
728
- buffer = lines.pop() || '';
729
- for (const line of lines) {
730
- const trimmed = line.trim();
731
- if (!trimmed || trimmed === 'data: [DONE]') {
732
- if (trimmed === 'data: [DONE]') {
733
- // Emit any completed tool calls
734
- for (const tc of toolCallsInProgress.values()) {
735
- yield { type: 'tool_call', toolCall: tc };
736
- }
737
- yield { type: 'done' };
738
- }
739
- continue;
740
- }
741
- if (!trimmed.startsWith('data: '))
742
- continue;
743
- try {
744
- const json = JSON.parse(trimmed.slice(6));
745
- const delta = json.choices?.[0]?.delta;
746
- if (!delta)
747
- continue;
748
- // Content delta
749
- if (delta.content) {
750
- yield { type: 'content', content: delta.content };
751
- }
752
- // Tool call deltas (OpenAI format)
753
- if (delta.tool_calls) {
754
- for (const tc of delta.tool_calls) {
755
- const idx = tc.index ?? 0;
756
- if (!toolCallsInProgress.has(idx)) {
757
- toolCallsInProgress.set(idx, {
758
- id: tc.id || '',
759
- name: tc.function?.name || '',
760
- arguments: '',
761
- });
762
- }
763
- const existing = toolCallsInProgress.get(idx);
764
- if (tc.id)
765
- existing.id = tc.id;
766
- if (tc.function?.name)
767
- existing.name = tc.function.name;
768
- if (tc.function?.arguments)
769
- existing.arguments += tc.function.arguments;
770
- }
771
- }
772
- }
773
- catch {
774
- // Skip malformed SSE lines
775
- }
776
- }
777
- }
778
- }
779
- finally {
780
- reader.releaseLock();
781
- }
782
- }
783
- // Convert Anthropic response format to OpenAI format
784
- convertAnthropicResponse(anthropicResponse, model) {
785
- const id = typeof anthropicResponse.id === 'string'
786
- ? anthropicResponse.id
787
- : `chatcmpl-${Date.now()}`;
788
- // Map Anthropic stop reasons to OpenAI format
789
- const stopReasonMap = {
790
- end_turn: 'stop',
791
- max_tokens: 'length',
792
- stop_sequence: 'stop',
793
- tool_use: 'tool_calls',
794
- };
795
- const stopReason = typeof anthropicResponse.stop_reason === 'string'
796
- ? stopReasonMap[anthropicResponse.stop_reason] || 'stop'
797
- : 'stop';
798
- let content = '';
799
- // Anthropic returns content as an array of content blocks
800
- if (Array.isArray(anthropicResponse.content)) {
801
- for (const block of anthropicResponse.content) {
802
- if (block &&
803
- typeof block === 'object' &&
804
- 'type' in block &&
805
- block.type === 'text' &&
806
- 'text' in block &&
807
- typeof block.text === 'string') {
808
- content += block.text;
809
- }
810
- }
811
- }
812
- let usage;
813
- if (anthropicResponse.usage &&
814
- typeof anthropicResponse.usage === 'object' &&
815
- anthropicResponse.usage !== null) {
816
- const usageObj = anthropicResponse.usage;
817
- const inputTokens = typeof usageObj.input_tokens === 'number' ? usageObj.input_tokens : 0;
818
- const outputTokens = typeof usageObj.output_tokens === 'number' ? usageObj.output_tokens : 0;
819
- usage = {
820
- prompt_tokens: inputTokens,
821
- completion_tokens: outputTokens,
822
- total_tokens: inputTokens + outputTokens,
823
- };
824
- }
825
- return {
826
- id,
827
- object: 'chat.completion',
828
- created: Math.floor(Date.now() / 1000),
829
- model,
830
- choices: [
831
- {
832
- index: 0,
833
- message: {
834
- role: 'assistant',
835
- content,
836
- },
837
- finish_reason: stopReason,
838
- },
839
- ],
840
- usage,
841
- };
842
- }
843
- // Convert Gemini response format to OpenAI format
844
- convertGeminiResponse(geminiResponse, model) {
845
- const id = `chatcmpl-${Date.now()}`;
846
- let content = '';
847
- let finishReason = 'stop';
848
- // Gemini returns candidates array
849
- if (Array.isArray(geminiResponse.candidates)) {
850
- const candidate = geminiResponse.candidates[0];
851
- if (candidate && typeof candidate === 'object') {
852
- const candidateObj = candidate;
853
- // Extract content from parts
854
- if (candidateObj.content && typeof candidateObj.content === 'object') {
855
- const contentObj = candidateObj.content;
856
- if (Array.isArray(contentObj.parts)) {
857
- for (const part of contentObj.parts) {
858
- if (part &&
859
- typeof part === 'object' &&
860
- 'text' in part &&
861
- typeof part.text === 'string') {
862
- content += part.text;
863
- }
864
- }
865
- }
866
- }
867
- // Map Gemini finish reason to OpenAI format
868
- if (typeof candidateObj.finishReason === 'string') {
869
- const finishReasonMap = {
870
- STOP: 'stop',
871
- MAX_TOKENS: 'length',
872
- SAFETY: 'content_filter',
873
- RECITATION: 'content_filter',
874
- OTHER: 'stop',
875
- };
876
- finishReason = finishReasonMap[candidateObj.finishReason] || 'stop';
877
- }
878
- }
879
- }
880
- // Extract usage if available
881
- let usage;
882
- if (geminiResponse.usageMetadata &&
883
- typeof geminiResponse.usageMetadata === 'object') {
884
- const usageObj = geminiResponse.usageMetadata;
885
- const promptTokens = typeof usageObj.promptTokenCount === 'number'
886
- ? usageObj.promptTokenCount
887
- : 0;
888
- const completionTokens = typeof usageObj.candidatesTokenCount === 'number'
889
- ? usageObj.candidatesTokenCount
890
- : 0;
891
- usage = {
892
- prompt_tokens: promptTokens,
893
- completion_tokens: completionTokens,
894
- total_tokens: promptTokens + completionTokens,
895
- };
896
- }
897
- return {
898
- id,
899
- object: 'chat.completion',
900
- created: Math.floor(Date.now() / 1000),
901
- model,
902
- choices: [
903
- {
904
- index: 0,
905
- message: {
906
- role: 'assistant',
907
- content,
908
- },
909
- finish_reason: finishReason,
910
- },
911
- ],
912
- usage,
913
- };
463
+ yield* streamOpenAICompatibleResponse(response);
914
464
  }
915
465
  // Validate plugin structure
916
466
  validatePlugin(plugin) {
@@ -948,814 +498,43 @@ class PluginService {
948
498
  return this.installPlugin(pluginData);
949
499
  }
950
500
  // ============================================
951
- // TTS (Text-to-Speech) Methods
501
+ // Capability Methods
952
502
  // ============================================
953
- // Get plugin that supports TTS for a specific model
954
503
  getPluginForTTS(model) {
955
- console.log(`[DEBUG] Looking for TTS plugin for model: ${model}`);
956
- const allPlugins = this.getAllPlugins();
957
- for (const plugin of allPlugins) {
958
- // Check if plugin has TTS capability
959
- if (plugin.capabilities?.tts) {
960
- const ttsCapability = plugin.capabilities.tts;
961
- if (ttsCapability.model_map.includes(model)) {
962
- console.log(`[DEBUG] Found TTS plugin ${plugin.id} for model ${model}`);
963
- // Check if no auth is required (e.g., local TTS servers)
964
- const noAuthRequired = ttsCapability.config
965
- ?.no_auth_required === true;
966
- // Check if we have the required API key (from DB or env)
967
- const apiKey = this.getApiKey(plugin);
968
- if (!apiKey && !noAuthRequired) {
969
- console.log(`[DEBUG] Plugin ${plugin.id} found but API key not set (checked DB and ${plugin.auth.key_env})`);
970
- continue;
971
- }
972
- return plugin;
973
- }
974
- }
975
- // Also check primary type for backward compatibility with TTS-only plugins
976
- if (plugin.type === 'tts' && plugin.model_map.includes(model)) {
977
- console.log(`[DEBUG] Found TTS-type plugin ${plugin.id} for model ${model}`);
978
- // Check if no auth is required
979
- const noAuthRequired = plugin.capabilities?.tts?.config?.no_auth_required === true;
980
- const apiKey = this.getApiKey(plugin);
981
- if (!apiKey && !noAuthRequired) {
982
- console.log(`[DEBUG] Plugin ${plugin.id} found but API key not set (checked DB and ${plugin.auth.key_env})`);
983
- continue;
984
- }
985
- return plugin;
986
- }
987
- }
988
- console.log(`[DEBUG] No TTS plugin found for model: ${model}`);
989
- return null;
504
+ return this.ttsService.getPluginForTTS(model);
505
+ }
506
+ getPluginForEmbedding(model, pluginId, userId) {
507
+ return this.embeddingService.getPluginForEmbedding(model, pluginId, userId);
508
+ }
509
+ getAvailableEmbeddingModels(userId) {
510
+ return this.embeddingService.getAvailableEmbeddingModels(userId);
511
+ }
512
+ async executeEmbeddingRequest(model, input, pluginId, userId) {
513
+ return this.embeddingService.executeEmbeddingRequest(model, input, pluginId, userId);
990
514
  }
991
- // Get all available TTS models from all plugins
992
515
  getAvailableTTSModels() {
993
- const models = [];
994
- const allPlugins = this.getAllPlugins();
995
- for (const plugin of allPlugins) {
996
- // Check capabilities-based TTS
997
- if (plugin.capabilities?.tts) {
998
- const ttsCapability = plugin.capabilities.tts;
999
- // Check if no auth is required (e.g., local TTS servers)
1000
- const noAuthRequired = ttsCapability.config
1001
- ?.no_auth_required === true;
1002
- // Check if API key is available (from DB or env)
1003
- const apiKey = this.getApiKey(plugin);
1004
- if (apiKey || noAuthRequired) {
1005
- for (const model of ttsCapability.model_map) {
1006
- models.push({
1007
- model,
1008
- plugin: plugin.id,
1009
- config: ttsCapability.config,
1010
- });
1011
- }
1012
- }
1013
- }
1014
- // Check primary type for TTS-only plugins
1015
- if (plugin.type === 'tts') {
1016
- // Check if no auth is required
1017
- const noAuthRequired = plugin.capabilities?.tts?.config?.no_auth_required === true;
1018
- const apiKey = this.getApiKey(plugin);
1019
- if (apiKey || noAuthRequired) {
1020
- for (const model of plugin.model_map) {
1021
- models.push({
1022
- model,
1023
- plugin: plugin.id,
1024
- });
1025
- }
1026
- }
1027
- }
1028
- }
1029
- return models;
516
+ return this.ttsService.getAvailableTTSModels();
1030
517
  }
1031
- // Execute a TTS request through the appropriate plugin
1032
518
  async executeTTSRequest(model, input, options = {}) {
1033
- // Validate model parameter
1034
- if (!model || typeof model !== 'string') {
1035
- throw new Error('Invalid model parameter: must be a non-empty string');
1036
- }
1037
- // Sanitize model parameter
1038
- const modelPattern = /^[a-zA-Z0-9\-_:./]+$/;
1039
- if (!modelPattern.test(model)) {
1040
- throw new Error(`Invalid model parameter: ${model} contains invalid characters`);
1041
- }
1042
- // Prevent path traversal
1043
- if (model.includes('..') || model.includes('\\')) {
1044
- throw new Error(`Invalid model parameter: ${model} contains invalid patterns`);
1045
- }
1046
- const plugin = this.getPluginForTTS(model);
1047
- if (!plugin) {
1048
- throw new Error(`No TTS plugin found for model: ${model}`);
1049
- }
1050
- // Determine endpoint and config
1051
- let endpoint;
1052
- let ttsConfig;
1053
- if (plugin.capabilities?.tts) {
1054
- endpoint = plugin.capabilities.tts.endpoint;
1055
- ttsConfig = plugin.capabilities.tts.config;
1056
- }
1057
- else {
1058
- endpoint = plugin.endpoint;
1059
- }
1060
- // Check if no auth is required (e.g., local TTS servers like Qwen3-TTS)
1061
- const noAuthRequired = ttsConfig?.no_auth_required ===
1062
- true;
1063
- // Get API key from database (per-user) or environment variable (fallback)
1064
- const apiKey = this.getApiKey(plugin);
1065
- if (!apiKey && !noAuthRequired) {
1066
- throw new Error(`API key not found for plugin ${plugin.id} (set via Settings or ${plugin.auth.key_env} env var)`);
1067
- }
1068
- // Prepare headers
1069
- const headers = {
1070
- 'Content-Type': 'application/json',
1071
- };
1072
- // Only add auth header if API key exists and auth header is configured
1073
- if (apiKey && plugin.auth.header) {
1074
- const authValue = plugin.auth.prefix
1075
- ? `${plugin.auth.prefix}${apiKey}`
1076
- : apiKey;
1077
- headers[plugin.auth.header] = authValue;
1078
- }
1079
- // Load plugin variables for TTS defaults
1080
- const ttsVars = this.getPluginVariables(plugin);
1081
- // Allow endpoint override via plugin variables
1082
- if (ttsVars.endpoint && typeof ttsVars.endpoint === 'string') {
1083
- const validated = this.validateEndpointUrl(ttsVars.endpoint);
1084
- if (validated)
1085
- endpoint = validated;
1086
- }
1087
- // Apply defaults from config, then plugin variables, then request options
1088
- const voice = options.voice || ttsConfig?.default_voice || 'alloy';
1089
- const responseFormat = options.response_format || ttsConfig?.default_format || 'mp3';
1090
- const speed = options.speed || ttsVars.speed || 1.0;
1091
- // Check if input needs chunking (for long texts)
1092
- const maxChars = ttsConfig?.max_characters || 4096;
1093
- if (input.length > maxChars) {
1094
- // Split text into chunks and process each, then concatenate audio
1095
- const chunks = this.splitTextForTTS(input, maxChars);
1096
- console.log(`[TTS] Input too long (${input.length} chars), splitting into ${chunks.length} chunks`);
1097
- const audioBuffers = [];
1098
- for (let i = 0; i < chunks.length; i++) {
1099
- console.log(`[TTS] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
1100
- // Recursive call with chunk (will not re-chunk since it's under limit)
1101
- const chunkAudio = await this.executeTTSRequest(model, chunks[i], options);
1102
- audioBuffers.push(chunkAudio);
1103
- }
1104
- // Concatenate all audio buffers
1105
- return Buffer.concat(audioBuffers);
1106
- }
1107
- // Prepare request payload and endpoint based on plugin type
1108
- let payload;
1109
- let processedEndpoint;
1110
- if (plugin.id === 'elevenlabs') {
1111
- // ElevenLabs API format
1112
- // ElevenLabs uses voice IDs - map voice names to IDs
1113
- const elevenLabsVoiceIds = {
1114
- rachel: '21m00Tcm4TlvDq8ikWAM',
1115
- domi: 'AZnzlk1XvdvUeBnXmlld',
1116
- bella: 'EXAVITQu4vr4xnSDxMaL',
1117
- antoni: 'ErXwobaYiN019PkySvjV',
1118
- elli: 'MF3mGyEYCl7XYWbV9V6O',
1119
- josh: 'TxGEqnHWrfWFTfGW9XjX',
1120
- arnold: 'VR6AewLTigWG4xSOukaG',
1121
- adam: 'pNInz6obpgDQGcFmaJgB',
1122
- sam: 'yoZ06aMxZJJ28mfd3POQ',
1123
- nicole: 'piTKgcLEGmPE4e6mEKli',
1124
- glinda: 'z9fAnlkpzviPz146aGWa',
1125
- clyde: '2EiwWnXFnvU5JabPnv8n',
1126
- james: 'ZQe5CZNOzWyzPSCn5a3c',
1127
- charlotte: 'XB0fDUnXU5powFXDhCwa',
1128
- lily: 'pFZP5JQG7iQjIQuC4Bku',
1129
- serena: 'pMsXgVXv3BLzUgSXRplE',
1130
- };
1131
- const voiceId = elevenLabsVoiceIds[voice.toLowerCase()] ||
1132
- elevenLabsVoiceIds['rachel'] ||
1133
- '21m00Tcm4TlvDq8ikWAM';
1134
- processedEndpoint = `${endpoint}/${voiceId}`;
1135
- // Add output_format query parameter
1136
- const formatMap = {
1137
- mp3: 'mp3_44100_128',
1138
- pcm: 'pcm_16000',
1139
- ulaw: 'ulaw_8000',
1140
- };
1141
- const outputFormat = formatMap[responseFormat] || 'mp3_44100_128';
1142
- processedEndpoint += `?output_format=${outputFormat}`;
1143
- payload = {
1144
- text: input,
1145
- model_id: model,
1146
- voice_settings: {
1147
- stability: ttsVars.stability ?? 0.5,
1148
- similarity_boost: ttsVars.similarity_boost ?? 0.75,
1149
- },
1150
- };
1151
- }
1152
- else {
1153
- // Default OpenAI TTS format
1154
- payload = {
1155
- model,
1156
- input,
1157
- voice,
1158
- response_format: responseFormat,
1159
- speed,
1160
- };
1161
- // Process endpoint template
1162
- const sanitizedModel = encodeURIComponent(model);
1163
- processedEndpoint = endpoint.replace('{model}', sanitizedModel);
1164
- }
1165
- // Validate the final endpoint URL
1166
- try {
1167
- const url = new URL(processedEndpoint);
1168
- const isLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
1169
- const isPrivateNetwork = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(url.hostname);
1170
- if (url.protocol !== 'https:' && !isLocalhost && !isPrivateNetwork) {
1171
- throw new Error(`Insecure endpoint protocol: ${url.protocol}. Only HTTPS is allowed for remote endpoints. ` +
1172
- `(HTTP is permitted for localhost and private network IPs)`);
1173
- }
1174
- }
1175
- catch (_error) {
1176
- throw new Error(`Invalid endpoint URL constructed: ${processedEndpoint}`);
1177
- }
1178
- // Debug logging for TTS request (redact sensitive headers)
1179
- const redactedHeaders = Object.fromEntries(Object.entries(headers).map(([key, value]) => [
1180
- key,
1181
- key.toLowerCase().includes('authorization') ||
1182
- key.toLowerCase().includes('api-key') ||
1183
- key.toLowerCase().includes('apikey') ||
1184
- key.toLowerCase().includes('token') ||
1185
- key.toLowerCase().includes('secret')
1186
- ? '[REDACTED]'
1187
- : value,
1188
- ]));
1189
- console.log(`[TTS Debug] Endpoint: ${processedEndpoint}`);
1190
- console.log(`[TTS Debug] Payload:`, JSON.stringify(payload, null, 2));
1191
- console.log(`[TTS Debug] Headers:`, JSON.stringify(redactedHeaders, null, 2));
1192
- try {
1193
- const response = await axios.post(processedEndpoint, payload, {
1194
- headers,
1195
- timeout: 120000, // 2 minute timeout for TTS
1196
- responseType: 'arraybuffer', // TTS returns binary audio data
1197
- });
1198
- return Buffer.from(response.data);
1199
- }
1200
- catch (error) {
1201
- console.error(`TTS plugin request failed for ${plugin.id}:`, error);
1202
- if (error && typeof error === 'object' && 'response' in error) {
1203
- const axiosError = error;
1204
- // Try to parse error message from response
1205
- let errorMessage = axiosError.response.statusText;
1206
- if (axiosError.response.data) {
1207
- try {
1208
- const errorText = Buffer.from(axiosError.response.data).toString('utf8');
1209
- console.error(`[TTS Debug] Error response body:`, errorText);
1210
- const errorJson = JSON.parse(errorText);
1211
- errorMessage =
1212
- errorJson.error?.message ||
1213
- errorJson.detail ||
1214
- errorJson.message ||
1215
- errorMessage;
1216
- }
1217
- catch {
1218
- // If not JSON, show raw text
1219
- const rawText = Buffer.from(axiosError.response.data).toString('utf8');
1220
- if (rawText) {
1221
- console.error(`[TTS Debug] Raw error response:`, rawText);
1222
- errorMessage = rawText.substring(0, 200);
1223
- }
1224
- }
1225
- }
1226
- throw new Error(`TTS API error: ${axiosError.response.status} - ${errorMessage}`);
1227
- }
1228
- else if (error && typeof error === 'object' && 'request' in error) {
1229
- throw new Error(`TTS connection error: Unable to reach ${processedEndpoint}`);
1230
- }
1231
- else {
1232
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1233
- throw new Error(`TTS error: ${errorMessage}`);
1234
- }
1235
- }
519
+ return this.ttsService.executeTTSRequest(model, input, options);
1236
520
  }
1237
- // Split text into chunks for TTS, trying to break at sentence boundaries
1238
- splitTextForTTS(text, maxChars) {
1239
- const chunks = [];
1240
- let remaining = text;
1241
- while (remaining.length > 0) {
1242
- if (remaining.length <= maxChars) {
1243
- chunks.push(remaining);
1244
- break;
1245
- }
1246
- // Try to find a good break point (sentence end) within the limit
1247
- let breakPoint = maxChars;
1248
- const searchStart = Math.max(0, maxChars - 500); // Look in last 500 chars for sentence end
1249
- // Look for sentence endings (. ! ?) followed by space or end
1250
- const sentenceEnders = ['. ', '! ', '? ', '.\n', '!\n', '?\n'];
1251
- let bestBreak = -1;
1252
- for (const ender of sentenceEnders) {
1253
- const lastIndex = remaining.lastIndexOf(ender, maxChars);
1254
- if (lastIndex > searchStart && lastIndex > bestBreak) {
1255
- bestBreak = lastIndex + ender.length;
1256
- }
1257
- }
1258
- if (bestBreak > searchStart) {
1259
- breakPoint = bestBreak;
1260
- }
1261
- else {
1262
- // Fall back to breaking at whitespace
1263
- const lastSpace = remaining.lastIndexOf(' ', maxChars);
1264
- if (lastSpace > searchStart) {
1265
- breakPoint = lastSpace + 1;
1266
- }
1267
- // If no good break found, just break at maxChars (may split mid-word)
1268
- }
1269
- chunks.push(remaining.slice(0, breakPoint).trim());
1270
- remaining = remaining.slice(breakPoint).trim();
1271
- }
1272
- return chunks.filter(chunk => chunk.length > 0);
1273
- }
1274
- // Get TTS configuration for a specific plugin
1275
521
  getTTSConfig(pluginId) {
1276
- const plugin = this.getPlugin(pluginId);
1277
- if (!plugin)
1278
- return null;
1279
- if (plugin.capabilities?.tts?.config) {
1280
- return plugin.capabilities.tts.config;
1281
- }
1282
- return null;
522
+ return this.ttsService.getTTSConfig(pluginId);
1283
523
  }
1284
- // ==================== Image Generation Methods ====================
1285
- // Get plugin that supports a specific image generation model
1286
524
  getPluginForImageGen(model) {
1287
- const allPlugins = this.getAllPlugins();
1288
- for (const plugin of allPlugins) {
1289
- // Check capabilities-based image generation
1290
- if (plugin.capabilities?.image) {
1291
- const imageCapability = plugin.capabilities.image;
1292
- if (imageCapability.model_map.includes(model)) {
1293
- return plugin;
1294
- }
1295
- }
1296
- // Check primary type for image-only plugins
1297
- if (plugin.type === 'image' && plugin.model_map.includes(model)) {
1298
- return plugin;
1299
- }
1300
- }
1301
- console.log(`[DEBUG] No image generation plugin found for model: ${model}`);
1302
- return null;
525
+ return this.imageGenerationService.getPluginForImageGen(model);
1303
526
  }
1304
- // Get all available image generation models from all plugins
1305
527
  getAvailableImageGenModels() {
1306
- const models = [];
1307
- const allPlugins = this.getAllPlugins();
1308
- for (const plugin of allPlugins) {
1309
- // Check capabilities-based image generation
1310
- if (plugin.capabilities?.image) {
1311
- const imageCapability = plugin.capabilities.image;
1312
- // Check if API key is available (from DB or env) or if no auth is required
1313
- const noAuthRequired = imageCapability.config
1314
- ?.no_auth_required === true;
1315
- const apiKey = this.getApiKey(plugin);
1316
- if (apiKey || noAuthRequired) {
1317
- for (const model of imageCapability.model_map) {
1318
- models.push({
1319
- model,
1320
- plugin: plugin.id,
1321
- config: imageCapability.config,
1322
- });
1323
- }
1324
- }
1325
- }
1326
- // Check primary type for image-only plugins
1327
- if (plugin.type === 'image') {
1328
- const apiKey = this.getApiKey(plugin);
1329
- if (apiKey) {
1330
- for (const model of plugin.model_map) {
1331
- models.push({
1332
- model,
1333
- plugin: plugin.id,
1334
- });
1335
- }
1336
- }
1337
- }
1338
- }
1339
- return models;
528
+ return this.imageGenerationService.getAvailableImageGenModels();
1340
529
  }
1341
- // Execute an image generation request through the appropriate plugin
1342
530
  async executeImageGenRequest(model, prompt, options = {}) {
1343
- // Validate model parameter
1344
- if (!model || typeof model !== 'string') {
1345
- throw new Error('Invalid model parameter: must be a non-empty string');
1346
- }
1347
- // Sanitize model parameter
1348
- const modelPattern = /^[a-zA-Z0-9\-_:./]+$/;
1349
- if (!modelPattern.test(model)) {
1350
- throw new Error(`Invalid model parameter: ${model} contains invalid characters`);
1351
- }
1352
- // Validate prompt
1353
- if (!prompt || typeof prompt !== 'string') {
1354
- throw new Error('Invalid prompt: must be a non-empty string');
1355
- }
1356
- const plugin = this.getPluginForImageGen(model);
1357
- if (!plugin) {
1358
- throw new Error(`No image generation plugin found for model: ${model}`);
1359
- }
1360
- // Determine endpoint and config first (needed for auth check)
1361
- let endpoint;
1362
- let imageConfig;
1363
- if (plugin.capabilities?.image) {
1364
- endpoint = plugin.capabilities.image.endpoint;
1365
- imageConfig = plugin.capabilities.image.config;
1366
- }
1367
- else {
1368
- endpoint = plugin.endpoint;
1369
- }
1370
- // Allow endpoint override via plugin variables
1371
- const imageVars = this.getPluginVariables(plugin);
1372
- if (imageVars.endpoint && typeof imageVars.endpoint === 'string') {
1373
- const validated = this.validateEndpointUrl(imageVars.endpoint);
1374
- if (validated)
1375
- endpoint = validated;
1376
- }
1377
- // Get API key from database (per-user) or environment variable (fallback)
1378
- // Some plugins (like local ComfyUI) don't require auth
1379
- const noAuthRequired = imageConfig?.no_auth_required ===
1380
- true;
1381
- const apiKey = this.getApiKey(plugin);
1382
- if (!apiKey && !noAuthRequired) {
1383
- throw new Error(`API key not found for plugin ${plugin.id} (set via Settings or ${plugin.auth.key_env} env var)`);
1384
- }
1385
- // Validate prompt length
1386
- if (imageConfig?.max_prompt_length &&
1387
- prompt.length > imageConfig.max_prompt_length) {
1388
- throw new Error(`Prompt exceeds maximum length of ${imageConfig.max_prompt_length} characters`);
1389
- }
1390
- // Build headers
1391
- const headers = {
1392
- 'Content-Type': 'application/json',
1393
- };
1394
- // Only add auth header if API key is available
1395
- if (apiKey) {
1396
- if (plugin.auth.prefix) {
1397
- headers[plugin.auth.header] = `${plugin.auth.prefix}${apiKey}`;
1398
- }
1399
- else {
1400
- headers[plugin.auth.header] = apiKey;
1401
- }
1402
- }
1403
- // Build payload (OpenAI-compatible format)
1404
- const payload = {
1405
- model,
1406
- prompt,
1407
- size: options.size || imageConfig?.default_size || '1024x1024',
1408
- quality: options.quality || imageConfig?.default_quality || 'standard',
1409
- n: options.n || 1,
1410
- response_format: options.response_format || 'url',
1411
- };
1412
- // Add style if supported
1413
- if (options.style || imageConfig?.default_style) {
1414
- payload.style = options.style || imageConfig?.default_style;
1415
- }
1416
- // Validate the final endpoint URL
1417
- let baseUrl;
1418
- try {
1419
- baseUrl = new URL(endpoint);
1420
- const isLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(baseUrl.hostname);
1421
- const isPrivateNetwork = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(baseUrl.hostname);
1422
- if (baseUrl.protocol !== 'https:' && !isLocalhost && !isPrivateNetwork) {
1423
- throw new Error(`Insecure endpoint protocol: ${baseUrl.protocol}. Only HTTPS is allowed for remote endpoints.`);
1424
- }
1425
- }
1426
- catch (_error) {
1427
- throw new Error(`Invalid endpoint URL: ${endpoint}`);
1428
- }
1429
- // Check if this is ComfyUI (special handling required)
1430
- if (plugin.id === 'comfyui' || endpoint.includes('/prompt')) {
1431
- return this.executeComfyUIRequest(baseUrl, prompt, {
1432
- ...options,
1433
- model,
1434
- pluginVars: this.getPluginVariables(plugin),
1435
- });
1436
- }
1437
- try {
1438
- const response = await axios.post(endpoint, payload, {
1439
- headers,
1440
- timeout: 120000, // 2 minute timeout for image generation
1441
- });
1442
- // Handle OpenAI-style response
1443
- if (response.data?.data) {
1444
- return {
1445
- images: response.data.data.map((img) => ({
1446
- url: img.url,
1447
- b64_json: img.b64_json,
1448
- revised_prompt: img.revised_prompt,
1449
- })),
1450
- model,
1451
- };
1452
- }
1453
- // Handle direct response format
1454
- return {
1455
- images: Array.isArray(response.data) ? response.data : [response.data],
1456
- model,
1457
- };
1458
- }
1459
- catch (error) {
1460
- if (axios.isAxiosError(error)) {
1461
- const message = error.response?.data?.error?.message ||
1462
- error.response?.data?.message ||
1463
- error.message;
1464
- throw new Error(`Image generation failed: ${message}`);
1465
- }
1466
- throw error;
1467
- }
531
+ return this.imageGenerationService.executeImageGenRequest(model, prompt, options);
1468
532
  }
1469
- // Execute ComfyUI image generation request (Flux.1 workflow)
1470
- async executeComfyUIRequest(baseUrl, prompt, options = {}) {
1471
- const comfyBaseUrl = `${baseUrl.protocol}//${baseUrl.host}`;
1472
- // Parse size
1473
- const size = options.size || '1024x1024';
1474
- const [width, height] = size.split('x').map(Number);
1475
- // Determine model-specific settings
1476
- const model = options.model || 'flux1-dev';
1477
- const modelConfigs = {
1478
- 'flux1-dev': {
1479
- unetFile: 'flux1-dev.safetensors',
1480
- t5File: 't5xxl_fp16.safetensors',
1481
- steps: { draft: 12, standard: 20, high: 28, ultra: 40 },
1482
- guidance: 3.5,
1483
- useCheckpointLoader: false,
1484
- },
1485
- 'flux1-dev-fp8': {
1486
- unetFile: 'flux1-dev-fp8.safetensors',
1487
- t5File: 't5xxl_fp8_e4m3fn_scaled.safetensors',
1488
- steps: { draft: 12, standard: 20, high: 28, ultra: 40 },
1489
- guidance: 3.5,
1490
- useCheckpointLoader: false,
1491
- },
1492
- 'flux1-schnell': {
1493
- unetFile: 'flux1-schnell.safetensors',
1494
- t5File: 't5xxl_fp16.safetensors',
1495
- steps: { draft: 2, standard: 4, high: 6, ultra: 8 },
1496
- guidance: 0, // Schnell doesn't use guidance
1497
- useCheckpointLoader: false,
1498
- },
1499
- };
1500
- const config = modelConfigs[model] || modelConfigs['flux1-dev'];
1501
- const quality = (options.quality ||
1502
- 'standard');
1503
- const pVars = options.pluginVars || {};
1504
- // Plugin variable overrides quality-based steps if set and non-default
1505
- const steps = pVars.steps && pVars.steps > 0
1506
- ? pVars.steps
1507
- : config.steps[quality] || config.steps.standard;
1508
- // Create a Flux.1 workflow for ComfyUI
1509
- // Flux uses UNET loader + dual CLIP + VAE separately
1510
- const workflow = {
1511
- '6': {
1512
- inputs: {
1513
- text: prompt,
1514
- clip: ['11', 0],
1515
- },
1516
- class_type: 'CLIPTextEncode',
1517
- _meta: { title: 'CLIP Text Encode (Prompt)' },
1518
- },
1519
- '8': {
1520
- inputs: {
1521
- samples: ['13', 0],
1522
- vae: ['10', 0],
1523
- },
1524
- class_type: 'VAEDecode',
1525
- _meta: { title: 'VAE Decode' },
1526
- },
1527
- '9': {
1528
- inputs: {
1529
- filename_prefix: `LibreWebUI_${model}`,
1530
- images: ['8', 0],
1531
- },
1532
- class_type: 'SaveImage',
1533
- _meta: { title: 'Save Image' },
1534
- },
1535
- '10': {
1536
- inputs: {
1537
- vae_name: 'ae.safetensors',
1538
- },
1539
- class_type: 'VAELoader',
1540
- _meta: { title: 'Load VAE' },
1541
- },
1542
- '11': {
1543
- inputs: {
1544
- clip_name1: 'clip_l.safetensors',
1545
- clip_name2: config.t5File,
1546
- type: 'flux',
1547
- },
1548
- class_type: 'DualCLIPLoader',
1549
- _meta: { title: 'DualCLIPLoader' },
1550
- },
1551
- '12': {
1552
- inputs: {
1553
- unet_name: config.unetFile,
1554
- weight_dtype: 'default',
1555
- },
1556
- class_type: 'UNETLoader',
1557
- _meta: { title: 'Load Diffusion Model' },
1558
- },
1559
- '13': {
1560
- inputs: {
1561
- noise: ['25', 0],
1562
- guider: ['22', 0],
1563
- sampler: ['16', 0],
1564
- sigmas: ['17', 0],
1565
- latent_image: ['27', 0],
1566
- },
1567
- class_type: 'SamplerCustomAdvanced',
1568
- _meta: { title: 'SamplerCustomAdvanced' },
1569
- },
1570
- '16': {
1571
- inputs: {
1572
- sampler_name: 'euler',
1573
- },
1574
- class_type: 'KSamplerSelect',
1575
- _meta: { title: 'KSamplerSelect' },
1576
- },
1577
- '17': {
1578
- inputs: {
1579
- scheduler: 'simple',
1580
- steps: steps,
1581
- denoise: 1,
1582
- model: ['12', 0],
1583
- },
1584
- class_type: 'BasicScheduler',
1585
- _meta: { title: 'BasicScheduler' },
1586
- },
1587
- '22': {
1588
- inputs: {
1589
- model: ['12', 0],
1590
- conditioning: config.guidance > 0 ? ['26', 0] : ['6', 0],
1591
- },
1592
- class_type: 'BasicGuider',
1593
- _meta: { title: 'BasicGuider' },
1594
- },
1595
- '25': {
1596
- inputs: {
1597
- noise_seed: pVars.seed && pVars.seed >= 0
1598
- ? pVars.seed
1599
- : Math.floor(Math.random() * 1000000000000000),
1600
- },
1601
- class_type: 'RandomNoise',
1602
- _meta: { title: 'RandomNoise' },
1603
- },
1604
- '27': {
1605
- inputs: {
1606
- width: width,
1607
- height: height,
1608
- batch_size: 1,
1609
- },
1610
- class_type: 'EmptySD3LatentImage',
1611
- _meta: { title: 'EmptySD3LatentImage' },
1612
- },
1613
- };
1614
- // Only add FluxGuidance node if guidance > 0 (not needed for schnell)
1615
- if (config.guidance > 0) {
1616
- workflow['26'] = {
1617
- inputs: {
1618
- guidance: pVars.cfg_scale && pVars.cfg_scale > 0
1619
- ? pVars.cfg_scale
1620
- : config.guidance,
1621
- conditioning: ['6', 0],
1622
- },
1623
- class_type: 'FluxGuidance',
1624
- _meta: { title: 'FluxGuidance' },
1625
- };
1626
- }
1627
- try {
1628
- // Generate a unique client ID
1629
- const clientId = `libre-webui-${Date.now()}`;
1630
- // Submit the workflow
1631
- const promptResponse = await axios.post(`${comfyBaseUrl}/prompt`, {
1632
- prompt: workflow,
1633
- client_id: clientId,
1634
- }, {
1635
- headers: { 'Content-Type': 'application/json' },
1636
- timeout: 10000,
1637
- });
1638
- const promptId = promptResponse.data.prompt_id;
1639
- if (!promptId) {
1640
- throw new Error('Failed to get prompt ID from ComfyUI');
1641
- }
1642
- // Poll for completion
1643
- let completed = false;
1644
- let attempts = 0;
1645
- const maxAttempts = 120; // 2 minutes with 1 second intervals
1646
- while (!completed && attempts < maxAttempts) {
1647
- await new Promise(resolve => setTimeout(resolve, 1000));
1648
- attempts++;
1649
- const historyResponse = await axios.get(`${comfyBaseUrl}/history/${promptId}`, { timeout: 5000 });
1650
- if (historyResponse.data[promptId]) {
1651
- const outputs = historyResponse.data[promptId].outputs;
1652
- if (outputs && Object.keys(outputs).length > 0) {
1653
- completed = true;
1654
- // Find the SaveImage output
1655
- for (const nodeId in outputs) {
1656
- const nodeOutput = outputs[nodeId];
1657
- if (nodeOutput.images && nodeOutput.images.length > 0) {
1658
- const imageInfo = nodeOutput.images[0];
1659
- // Get the image data
1660
- const imageUrl = `${comfyBaseUrl}/view?filename=${encodeURIComponent(imageInfo.filename)}&subfolder=${encodeURIComponent(imageInfo.subfolder || '')}&type=${encodeURIComponent(imageInfo.type || 'output')}`;
1661
- // Fetch image and convert to base64
1662
- const imageResponse = await axios.get(imageUrl, {
1663
- responseType: 'arraybuffer',
1664
- timeout: 30000,
1665
- });
1666
- const base64Image = Buffer.from(imageResponse.data).toString('base64');
1667
- return {
1668
- images: [
1669
- {
1670
- b64_json: base64Image,
1671
- revised_prompt: prompt,
1672
- },
1673
- ],
1674
- model,
1675
- };
1676
- }
1677
- }
1678
- }
1679
- }
1680
- }
1681
- if (!completed) {
1682
- throw new Error('ComfyUI generation timed out');
1683
- }
1684
- throw new Error('No image output found from ComfyUI');
1685
- }
1686
- catch (error) {
1687
- if (axios.isAxiosError(error)) {
1688
- const message = error.response?.data?.error ||
1689
- error.response?.data?.message ||
1690
- error.message;
1691
- throw new Error(`ComfyUI generation failed: ${message}`);
1692
- }
1693
- throw error;
1694
- }
1695
- }
1696
- // Get image generation configuration for a specific plugin
1697
533
  getImageGenConfig(pluginId) {
1698
- const plugin = this.getPlugin(pluginId);
1699
- if (!plugin)
1700
- return null;
1701
- if (plugin.capabilities?.image?.config) {
1702
- return plugin.capabilities.image.config;
1703
- }
1704
- return null;
534
+ return this.imageGenerationService.getImageGenConfig(pluginId);
1705
535
  }
1706
- // Get all plugins that support a specific capability type
1707
536
  getPluginsByCapability(capabilityType) {
1708
- const allPlugins = this.getAllPlugins();
1709
- const result = [];
1710
- for (const plugin of allPlugins) {
1711
- // Check if primary type matches
1712
- if (plugin.type === capabilityType) {
1713
- // Check if no auth is required for image plugins
1714
- const noAuthRequired = capabilityType === 'image' &&
1715
- plugin.capabilities?.image?.config?.no_auth_required === true;
1716
- const apiKey = this.getApiKey(plugin);
1717
- if (apiKey || noAuthRequired) {
1718
- result.push(plugin);
1719
- }
1720
- continue;
1721
- }
1722
- // Check capabilities object based on capability type
1723
- if (plugin.capabilities) {
1724
- let hasCapability = false;
1725
- let noAuthRequired = false;
1726
- switch (capabilityType) {
1727
- case 'tts':
1728
- hasCapability = !!plugin.capabilities.tts;
1729
- // Check if no auth is required for TTS capability (e.g., local servers)
1730
- noAuthRequired =
1731
- plugin.capabilities.tts?.config?.no_auth_required === true;
1732
- break;
1733
- case 'stt':
1734
- hasCapability = !!plugin.capabilities.stt;
1735
- break;
1736
- case 'embedding':
1737
- hasCapability = !!plugin.capabilities.embedding;
1738
- break;
1739
- case 'image':
1740
- hasCapability = !!plugin.capabilities.image;
1741
- // Check if no auth is required for image capability
1742
- noAuthRequired =
1743
- plugin.capabilities.image?.config?.no_auth_required === true;
1744
- break;
1745
- case 'completion':
1746
- case 'chat':
1747
- hasCapability = !!plugin.capabilities.completion;
1748
- break;
1749
- }
1750
- if (hasCapability) {
1751
- const apiKey = this.getApiKey(plugin);
1752
- if (apiKey || noAuthRequired) {
1753
- result.push(plugin);
1754
- }
1755
- }
1756
- }
1757
- }
1758
- return result;
537
+ return this.capabilityRegistryService.getPluginsByCapability(capabilityType);
1759
538
  }
1760
539
  }
1761
540
  export default new PluginService();