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