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.
- package/README.md +64 -46
- package/backend/dist/db.d.ts.map +1 -1
- package/backend/dist/db.js +21 -19
- package/backend/dist/db.js.map +1 -1
- package/backend/dist/index.d.ts.map +1 -1
- package/backend/dist/index.js +31 -944
- package/backend/dist/index.js.map +1 -1
- package/backend/dist/middleware/auth.d.ts.map +1 -1
- package/backend/dist/middleware/auth.js +7 -20
- package/backend/dist/middleware/auth.js.map +1 -1
- package/backend/dist/middleware/index.d.ts.map +1 -1
- package/backend/dist/middleware/index.js +4 -2
- package/backend/dist/middleware/index.js.map +1 -1
- package/backend/dist/models/personaModel.d.ts.map +1 -1
- package/backend/dist/models/personaModel.js +11 -9
- package/backend/dist/models/personaModel.js.map +1 -1
- package/backend/dist/routes/auth.d.ts.map +1 -1
- package/backend/dist/routes/auth.js +36 -14
- package/backend/dist/routes/auth.js.map +1 -1
- package/backend/dist/routes/chat.d.ts.map +1 -1
- package/backend/dist/routes/chat.js +61 -242
- package/backend/dist/routes/chat.js.map +1 -1
- package/backend/dist/routes/documents.d.ts.map +1 -1
- package/backend/dist/routes/documents.js +10 -8
- package/backend/dist/routes/documents.js.map +1 -1
- package/backend/dist/routes/embeddings.d.ts +3 -0
- package/backend/dist/routes/embeddings.d.ts.map +1 -0
- package/backend/dist/routes/embeddings.js +52 -0
- package/backend/dist/routes/embeddings.js.map +1 -0
- package/backend/dist/routes/imageGen.d.ts.map +1 -1
- package/backend/dist/routes/imageGen.js +9 -7
- package/backend/dist/routes/imageGen.js.map +1 -1
- package/backend/dist/routes/ollama.d.ts.map +1 -1
- package/backend/dist/routes/ollama.js +22 -239
- package/backend/dist/routes/ollama.js.map +1 -1
- package/backend/dist/routes/personas.d.ts.map +1 -1
- package/backend/dist/routes/personas.js +3 -1
- package/backend/dist/routes/personas.js.map +1 -1
- package/backend/dist/routes/plugins.d.ts.map +1 -1
- package/backend/dist/routes/plugins.js +7 -105
- package/backend/dist/routes/plugins.js.map +1 -1
- package/backend/dist/routes/tts.d.ts.map +1 -1
- package/backend/dist/routes/tts.js +7 -5
- package/backend/dist/routes/tts.js.map +1 -1
- package/backend/dist/routes/users.d.ts.map +1 -1
- package/backend/dist/routes/users.js +7 -5
- package/backend/dist/routes/users.js.map +1 -1
- package/backend/dist/services/assistantCompletionService.d.ts +26 -0
- package/backend/dist/services/assistantCompletionService.d.ts.map +1 -0
- package/backend/dist/services/assistantCompletionService.js +53 -0
- package/backend/dist/services/assistantCompletionService.js.map +1 -0
- package/backend/dist/services/authService.d.ts +2 -0
- package/backend/dist/services/authService.d.ts.map +1 -1
- package/backend/dist/services/authService.js +15 -7
- package/backend/dist/services/authService.js.map +1 -1
- package/backend/dist/services/chatGenerationService.d.ts +32 -0
- package/backend/dist/services/chatGenerationService.d.ts.map +1 -0
- package/backend/dist/services/chatGenerationService.js +118 -0
- package/backend/dist/services/chatGenerationService.js.map +1 -0
- package/backend/dist/services/chatRequestService.d.ts +55 -0
- package/backend/dist/services/chatRequestService.d.ts.map +1 -0
- package/backend/dist/services/chatRequestService.js +103 -0
- package/backend/dist/services/chatRequestService.js.map +1 -0
- package/backend/dist/services/chatService.d.ts +0 -1
- package/backend/dist/services/chatService.d.ts.map +1 -1
- package/backend/dist/services/chatService.js +17 -113
- package/backend/dist/services/chatService.js.map +1 -1
- package/backend/dist/services/documentService.d.ts.map +1 -1
- package/backend/dist/services/documentService.js +23 -21
- package/backend/dist/services/documentService.js.map +1 -1
- package/backend/dist/services/embeddingService.d.ts +9 -0
- package/backend/dist/services/embeddingService.d.ts.map +1 -0
- package/backend/dist/services/embeddingService.js +155 -0
- package/backend/dist/services/embeddingService.js.map +1 -0
- package/backend/dist/services/encryptionService.d.ts.map +1 -1
- package/backend/dist/services/encryptionService.js +26 -24
- package/backend/dist/services/encryptionService.js.map +1 -1
- package/backend/dist/services/galleryService.d.ts.map +1 -1
- package/backend/dist/services/galleryService.js +8 -6
- package/backend/dist/services/galleryService.js.map +1 -1
- package/backend/dist/services/memoryService.d.ts +2 -13
- package/backend/dist/services/memoryService.d.ts.map +1 -1
- package/backend/dist/services/memoryService.js +45 -211
- package/backend/dist/services/memoryService.js.map +1 -1
- package/backend/dist/services/memoryUtils.d.ts +29 -0
- package/backend/dist/services/memoryUtils.d.ts.map +1 -0
- package/backend/dist/services/memoryUtils.js +165 -0
- package/backend/dist/services/memoryUtils.js.map +1 -0
- package/backend/dist/services/mutationEngineService.d.ts.map +1 -1
- package/backend/dist/services/mutationEngineService.js +5 -3
- package/backend/dist/services/mutationEngineService.js.map +1 -1
- package/backend/dist/services/ollamaService.d.ts.map +1 -1
- package/backend/dist/services/ollamaService.js +67 -34
- package/backend/dist/services/ollamaService.js.map +1 -1
- package/backend/dist/services/personaService.d.ts.map +1 -1
- package/backend/dist/services/personaService.js +6 -2
- package/backend/dist/services/personaService.js.map +1 -1
- package/backend/dist/services/pluginCapabilityRegistryService.d.ts +11 -0
- package/backend/dist/services/pluginCapabilityRegistryService.d.ts.map +1 -0
- package/backend/dist/services/pluginCapabilityRegistryService.js +70 -0
- package/backend/dist/services/pluginCapabilityRegistryService.js.map +1 -0
- package/backend/dist/services/pluginCredentialsService.d.ts.map +1 -1
- package/backend/dist/services/pluginCredentialsService.js +13 -11
- package/backend/dist/services/pluginCredentialsService.js.map +1 -1
- package/backend/dist/services/pluginEmbeddingService.d.ts +26 -0
- package/backend/dist/services/pluginEmbeddingService.d.ts.map +1 -0
- package/backend/dist/services/pluginEmbeddingService.js +159 -0
- package/backend/dist/services/pluginEmbeddingService.js.map +1 -0
- package/backend/dist/services/pluginImageGenerationService.d.ts +29 -0
- package/backend/dist/services/pluginImageGenerationService.d.ts.map +1 -0
- package/backend/dist/services/pluginImageGenerationService.js +402 -0
- package/backend/dist/services/pluginImageGenerationService.js.map +1 -0
- package/backend/dist/services/pluginService.d.ts +21 -17
- package/backend/dist/services/pluginService.d.ts.map +1 -1
- package/backend/dist/services/pluginService.js +135 -1356
- package/backend/dist/services/pluginService.js.map +1 -1
- package/backend/dist/services/pluginTTSService.d.ts +28 -0
- package/backend/dist/services/pluginTTSService.d.ts.map +1 -0
- package/backend/dist/services/pluginTTSService.js +286 -0
- package/backend/dist/services/pluginTTSService.js.map +1 -0
- package/backend/dist/services/pluginVariablesService.d.ts.map +1 -1
- package/backend/dist/services/pluginVariablesService.js +7 -5
- package/backend/dist/services/pluginVariablesService.js.map +1 -1
- package/backend/dist/services/preferencesService.d.ts +1 -0
- package/backend/dist/services/preferencesService.d.ts.map +1 -1
- package/backend/dist/services/preferencesService.js +21 -7
- package/backend/dist/services/preferencesService.js.map +1 -1
- package/backend/dist/services/simpleGitHubOAuth.d.ts.map +1 -1
- package/backend/dist/services/simpleGitHubOAuth.js +10 -8
- package/backend/dist/services/simpleGitHubOAuth.js.map +1 -1
- package/backend/dist/services/simpleHuggingFaceOAuth.d.ts.map +1 -1
- package/backend/dist/services/simpleHuggingFaceOAuth.js +11 -9
- package/backend/dist/services/simpleHuggingFaceOAuth.js.map +1 -1
- package/backend/dist/services/systemSettingsService.d.ts.map +1 -1
- package/backend/dist/services/systemSettingsService.js +4 -2
- package/backend/dist/services/systemSettingsService.js.map +1 -1
- package/backend/dist/services/titleGenerationService.d.ts +55 -0
- package/backend/dist/services/titleGenerationService.d.ts.map +1 -0
- package/backend/dist/services/titleGenerationService.js +121 -0
- package/backend/dist/services/titleGenerationService.js.map +1 -0
- package/backend/dist/services/turnstileService.d.ts +13 -0
- package/backend/dist/services/turnstileService.d.ts.map +1 -0
- package/backend/dist/services/turnstileService.js +79 -0
- package/backend/dist/services/turnstileService.js.map +1 -0
- package/backend/dist/storage.d.ts +2 -22
- package/backend/dist/storage.d.ts.map +1 -1
- package/backend/dist/storage.js +22 -170
- package/backend/dist/storage.js.map +1 -1
- package/backend/dist/storageMappers.d.ts +82 -0
- package/backend/dist/storageMappers.d.ts.map +1 -0
- package/backend/dist/storageMappers.js +85 -0
- package/backend/dist/storageMappers.js.map +1 -0
- package/backend/dist/test-encryption.js +20 -18
- package/backend/dist/test-encryption.js.map +1 -1
- package/backend/dist/types/index.d.ts +6 -0
- package/backend/dist/types/index.d.ts.map +1 -1
- package/backend/dist/types/index.js.map +1 -1
- package/backend/dist/utils/assistantBranching.d.ts +8 -0
- package/backend/dist/utils/assistantBranching.d.ts.map +1 -0
- package/backend/dist/utils/assistantBranching.js +30 -0
- package/backend/dist/utils/assistantBranching.js.map +1 -0
- package/backend/dist/utils/chatContext.d.ts +17 -0
- package/backend/dist/utils/chatContext.d.ts.map +1 -0
- package/backend/dist/utils/chatContext.js +82 -0
- package/backend/dist/utils/chatContext.js.map +1 -0
- package/backend/dist/utils/chatDocumentContext.d.ts +7 -0
- package/backend/dist/utils/chatDocumentContext.d.ts.map +1 -0
- package/backend/dist/utils/chatDocumentContext.js +51 -0
- package/backend/dist/utils/chatDocumentContext.js.map +1 -0
- package/backend/dist/utils/logger.d.ts +12 -0
- package/backend/dist/utils/logger.d.ts.map +1 -0
- package/backend/dist/utils/logger.js +82 -0
- package/backend/dist/utils/logger.js.map +1 -0
- package/backend/dist/utils/ollamaLibrary.d.ts +17 -0
- package/backend/dist/utils/ollamaLibrary.d.ts.map +1 -0
- package/backend/dist/utils/ollamaLibrary.js +253 -0
- package/backend/dist/utils/ollamaLibrary.js.map +1 -0
- package/backend/dist/utils/ollamaStreaming.d.ts +19 -0
- package/backend/dist/utils/ollamaStreaming.d.ts.map +1 -0
- package/backend/dist/utils/ollamaStreaming.js +67 -0
- package/backend/dist/utils/ollamaStreaming.js.map +1 -0
- package/backend/dist/utils/packagePaths.d.ts +10 -0
- package/backend/dist/utils/packagePaths.d.ts.map +1 -0
- package/backend/dist/utils/packagePaths.js +81 -0
- package/backend/dist/utils/packagePaths.js.map +1 -0
- package/backend/dist/utils/pluginChatAdapter.d.ts +32 -0
- package/backend/dist/utils/pluginChatAdapter.d.ts.map +1 -0
- package/backend/dist/utils/pluginChatAdapter.js +308 -0
- package/backend/dist/utils/pluginChatAdapter.js.map +1 -0
- package/backend/dist/utils/pluginChatContext.d.ts +23 -0
- package/backend/dist/utils/pluginChatContext.d.ts.map +1 -0
- package/backend/dist/utils/pluginChatContext.js +77 -0
- package/backend/dist/utils/pluginChatContext.js.map +1 -0
- package/backend/dist/utils/pluginResponse.d.ts +6 -0
- package/backend/dist/utils/pluginResponse.d.ts.map +1 -0
- package/backend/dist/utils/pluginResponse.js +70 -0
- package/backend/dist/utils/pluginResponse.js.map +1 -0
- package/backend/dist/utils/pluginStreamAdapter.d.ts +16 -0
- package/backend/dist/utils/pluginStreamAdapter.d.ts.map +1 -0
- package/backend/dist/utils/pluginStreamAdapter.js +141 -0
- package/backend/dist/utils/pluginStreamAdapter.js.map +1 -0
- package/backend/dist/utils/pluginStreaming.d.ts +24 -0
- package/backend/dist/utils/pluginStreaming.d.ts.map +1 -0
- package/backend/dist/utils/pluginStreaming.js +106 -0
- package/backend/dist/utils/pluginStreaming.js.map +1 -0
- package/backend/dist/utils/pluginUpload.d.ts +8 -0
- package/backend/dist/utils/pluginUpload.d.ts.map +1 -0
- package/backend/dist/utils/pluginUpload.js +51 -0
- package/backend/dist/utils/pluginUpload.js.map +1 -0
- package/backend/dist/utils/pluginValidation.d.ts +10 -0
- package/backend/dist/utils/pluginValidation.d.ts.map +1 -0
- package/backend/dist/utils/pluginValidation.js +94 -0
- package/backend/dist/utils/pluginValidation.js.map +1 -0
- package/backend/dist/utils/pluginVariableValidation.d.ts +10 -0
- package/backend/dist/utils/pluginVariableValidation.d.ts.map +1 -0
- package/backend/dist/utils/pluginVariableValidation.js +80 -0
- package/backend/dist/utils/pluginVariableValidation.js.map +1 -0
- package/backend/dist/utils/websocketMessages.d.ts +26 -0
- package/backend/dist/utils/websocketMessages.d.ts.map +1 -0
- package/backend/dist/utils/websocketMessages.js +74 -0
- package/backend/dist/utils/websocketMessages.js.map +1 -0
- package/backend/dist/websocketServer.d.ts +3 -0
- package/backend/dist/websocketServer.d.ts.map +1 -0
- package/backend/dist/websocketServer.js +332 -0
- package/backend/dist/websocketServer.js.map +1 -0
- package/backend/package.json +55 -0
- package/frontend/dist/assets/index-BxHDVIti.css +1 -0
- package/frontend/dist/assets/index-CDA2f43l.js +5 -0
- package/frontend/dist/assets/markdown-math-CYlHEDRF.css +1 -0
- package/frontend/dist/index.html +12 -15
- package/frontend/dist/js/ArtifactContainer-CA523BOS.js +1 -0
- package/frontend/dist/js/ArtifactDemoPage-C0p2-ccP.js +200 -0
- package/frontend/dist/js/ArtifactSlideOutPanel-BBYuYyr_.js +1 -0
- package/frontend/dist/js/Button-BmZQkAPD.js +1 -0
- package/frontend/dist/js/ChatPage-QSOhIynr.js +16 -0
- package/frontend/dist/js/FirstTimeSetup-pi-S4VU-.js +1 -0
- package/frontend/dist/js/GalleryPage-BPXB-D4f.js +1 -0
- package/frontend/dist/js/ModelManagerModals-D3SZV7CU.js +1 -0
- package/frontend/dist/js/ModelsPage-Fmqc1Zxb.js +2 -0
- package/frontend/dist/js/OptimizedSyntaxHighlighter-DlI4omE2.js +1 -0
- package/frontend/dist/js/PersonasPage-BuLOCG1Y.js +13 -0
- package/frontend/dist/js/RichMessageContent-Darvuw8F.js +3 -0
- package/frontend/dist/js/SettingsModal-CQhAeaia.js +1 -0
- package/frontend/dist/js/UserManagementPage-yX9vOase.js +1 -0
- package/frontend/dist/js/api-d05r9G59.js +15 -0
- package/frontend/dist/js/ar-BIL8U_dv.js +2 -0
- package/frontend/dist/js/artifactHtml-CRdkOCnz.js +27 -0
- package/frontend/dist/js/bn-BWQoWPn6.js +2 -0
- package/frontend/dist/js/cs-DOD1vscE.js +2 -0
- package/frontend/dist/js/da-DYPzSzX4.js +2 -0
- package/frontend/dist/js/de-CHd4b2Xf.js +2 -0
- package/frontend/dist/js/demoMode-BhEpMVh5.js +1 -0
- package/frontend/dist/js/es-BmDO6uNe.js +2 -0
- package/frontend/dist/js/fr-DW24sUV7.js +2 -0
- package/frontend/dist/js/hi-BRDq_y1B.js +2 -0
- package/frontend/dist/js/id-I_F2nstV.js +2 -0
- package/frontend/dist/js/is-Boj7-J12.js +2 -0
- package/frontend/dist/js/it-CNeHl9Fh.js +2 -0
- package/frontend/dist/js/ja-DrVG6G8s.js +2 -0
- package/frontend/dist/js/ko-BtKRwlHW.js +2 -0
- package/frontend/dist/js/logger-1THq8-96.js +1 -0
- package/frontend/dist/js/markdown-core-CEpaJrKd.js +16 -0
- package/frontend/dist/js/markdown-math-DAmpvM9y.js +261 -0
- package/frontend/dist/js/ms-Dp9MigoU.js +2 -0
- package/frontend/dist/js/nl-Bk9pGoCF.js +2 -0
- package/frontend/dist/js/pl-seE8_lLS.js +2 -0
- package/frontend/dist/js/pt-BAeBtldI.js +2 -0
- package/frontend/dist/js/{react-vendor-BUzyfgnZ.js → react-vendor-DtQrJICq.js} +4 -4
- package/frontend/dist/js/rolldown-runtime-QTnfLwEv.js +1 -0
- package/frontend/dist/js/ru-CkCpWFNb.js +2 -0
- package/frontend/dist/js/sv-BM7nqxmA.js +2 -0
- package/frontend/dist/js/syntax-highlight-B758xPeo.js +8 -0
- package/frontend/dist/js/th-vDIZbpa4.js +2 -0
- package/frontend/dist/js/tr-G2-XodhW.js +2 -0
- package/frontend/dist/js/ui-vendor-MCs1MDmN.js +177 -0
- package/frontend/dist/js/uk-DfVfZcBQ.js +2 -0
- package/frontend/dist/js/useBaseQuery-CrdrLcmR.js +1 -0
- package/frontend/dist/js/useQuery-DaEnYfWR.js +1 -0
- package/frontend/dist/js/utils-es0n-47H.js +1 -0
- package/frontend/dist/js/utils-vendor-B2ZBL57Y.js +9 -0
- package/frontend/dist/js/vi-Yp9yjb06.js +2 -0
- package/frontend/dist/js/zh-DtRb97we.js +2 -0
- package/package.json +16 -10
- package/plugins/llama-cpp.json +93 -0
- package/plugins/openclaw-agent.json +1 -1
- package/backend/dist/services/openclawSessionService.d.ts +0 -63
- package/backend/dist/services/openclawSessionService.d.ts.map +0 -1
- package/backend/dist/services/openclawSessionService.js +0 -307
- package/backend/dist/services/openclawSessionService.js.map +0 -1
- package/frontend/dist/assets/index-B1qNw5pA.js +0 -52
- package/frontend/dist/assets/index-DnG6512Q.css +0 -1
- package/frontend/dist/assets/ui-CsNe6lFV.css +0 -1
- package/frontend/dist/js/ArtifactContainer-Com7P5vK.js +0 -24
- package/frontend/dist/js/ArtifactDemoPage-Xd-Ragpf.js +0 -98
- package/frontend/dist/js/Button-D62PCbMW.js +0 -1
- package/frontend/dist/js/ChatPage-01JxVHRP.js +0 -15
- package/frontend/dist/js/GalleryPage-8nHGqKEu.js +0 -1
- package/frontend/dist/js/ModelsPage-C0MLVfwA.js +0 -1
- package/frontend/dist/js/OptimizedSyntaxHighlighter-DjnSqskO.js +0 -1
- package/frontend/dist/js/PersonasPage-D9YKnYz6.js +0 -13
- package/frontend/dist/js/UserManagementPage-b6NZdPTD.js +0 -1
- package/frontend/dist/js/api-B0pf9vdR.js +0 -1
- package/frontend/dist/js/api-BrzQvj_g.js +0 -3
- package/frontend/dist/js/appStore-k_e3JAM3.js +0 -2
- package/frontend/dist/js/authStore-BkobbhZS.js +0 -1
- package/frontend/dist/js/authStore-ge66S03D.js +0 -2
- package/frontend/dist/js/axios-CYrHjdt1.js +0 -1
- package/frontend/dist/js/demoMode-qyEGd_UM.js +0 -1
- package/frontend/dist/js/markdown-vendor-4lCPqxk5.js +0 -22
- package/frontend/dist/js/rolldown-runtime-DWy-cpC1.js +0 -1
- package/frontend/dist/js/ui-DuH1cD2f.js +0 -267
- package/frontend/dist/js/ui-vendor-BFOABH-A.js +0 -177
- package/frontend/dist/js/userService-BndgbS1C.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
logger.error(`Failed to load plugin ${file}:`, error);
|
|
181
222
|
}
|
|
182
223
|
}
|
|
183
224
|
}
|
|
184
225
|
}
|
|
185
226
|
catch (error) {
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
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:
|
|
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
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
501
|
+
// Capability Methods
|
|
952
502
|
// ============================================
|
|
953
|
-
// Get plugin that supports TTS for a specific model
|
|
954
503
|
getPluginForTTS(model) {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|