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.
- package/README.md +75 -61
- 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 +39 -908
- package/backend/dist/index.js.map +1 -1
- package/backend/dist/middleware/auth.d.ts.map +1 -1
- package/backend/dist/middleware/auth.js +4 -2
- 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 -249
- 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/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/libreClaw.d.ts +3 -0
- package/backend/dist/routes/libreClaw.d.ts.map +1 -0
- package/backend/dist/routes/libreClaw.js +156 -0
- package/backend/dist/routes/libreClaw.js.map +1 -0
- package/backend/dist/routes/ollama.d.ts.map +1 -1
- package/backend/dist/routes/ollama.js +7 -247
- package/backend/dist/routes/ollama.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 +9 -5
- 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 +13 -27
- 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 +21 -19
- package/backend/dist/services/documentService.js.map +1 -1
- package/backend/dist/services/embeddingService.d.ts.map +1 -1
- package/backend/dist/services/embeddingService.js +3 -1
- package/backend/dist/services/embeddingService.js.map +1 -1
- 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/libreClawService.d.ts +105 -0
- package/backend/dist/services/libreClawService.d.ts.map +1 -0
- package/backend/dist/services/libreClawService.js +169 -0
- package/backend/dist/services/libreClawService.js.map +1 -0
- 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 +37 -198
- 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 +3 -1
- 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 +36 -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 +3 -1
- 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 +9 -18
- package/backend/dist/services/pluginService.d.ts.map +1 -1
- package/backend/dist/services/pluginService.js +118 -1469
- 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.map +1 -1
- package/backend/dist/services/preferencesService.js +9 -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.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/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 +9 -0
- package/backend/dist/utils/pluginValidation.d.ts.map +1 -0
- package/backend/dist/utils/pluginValidation.js +84 -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 +2 -2
- package/frontend/dist/assets/index-Dc8Q4dJZ.css +1 -0
- package/frontend/dist/assets/index-ONn9VIq7.js +5 -0
- package/frontend/dist/assets/markdown-math-CYlHEDRF.css +1 -0
- package/frontend/dist/index.html +11 -10
- package/frontend/dist/js/ArtifactContainer-CqKsiKz0.js +1 -0
- package/frontend/dist/js/ArtifactDemoPage-Dd5BjIu2.js +200 -0
- package/frontend/dist/js/ArtifactSlideOutPanel-Yc7ttIaY.js +1 -0
- package/frontend/dist/js/Button-BUjLpHmR.js +1 -0
- package/frontend/dist/js/ChatPage-Cpy7lQ0D.js +16 -0
- package/frontend/dist/js/FirstTimeSetup-DcGhm0H0.js +1 -0
- package/frontend/dist/js/GalleryPage-DNI2Evvf.js +1 -0
- package/frontend/dist/js/LibreClawPage-CmTiROsJ.js +1 -0
- package/frontend/dist/js/ModelManagerModals-CfvSeNsT.js +1 -0
- package/frontend/dist/js/ModelsPage-CeTGqx4q.js +2 -0
- package/frontend/dist/js/OptimizedSyntaxHighlighter-CNjLqj1Z.js +1 -0
- package/frontend/dist/js/PersonasPage-BUqu7gJ3.js +13 -0
- package/frontend/dist/js/RichMessageContent-B0JO8vkR.js +3 -0
- package/frontend/dist/js/SettingsModal-B5aJ5vfV.js +1 -0
- package/frontend/dist/js/UserManagementPage-BqIIWX2M.js +1 -0
- package/frontend/dist/js/api-CbmZzKBu.js +15 -0
- package/frontend/dist/js/ar-BAOdgn6O.js +2 -0
- package/frontend/dist/js/artifactHtml-B7Oy2e__.js +27 -0
- package/frontend/dist/js/bn-Bp2KS5mx.js +2 -0
- package/frontend/dist/js/cs-DOgJ0c5p.js +2 -0
- package/frontend/dist/js/da-BCKTkC35.js +2 -0
- package/frontend/dist/js/de-BXyECph5.js +2 -0
- package/frontend/dist/js/demoMode-BhEpMVh5.js +1 -0
- package/frontend/dist/js/es-Db4bDYIF.js +2 -0
- package/frontend/dist/js/fr-BPIkf6zH.js +2 -0
- package/frontend/dist/js/hi-Bm8D_Aux.js +2 -0
- package/frontend/dist/js/id-DiWaH74_.js +2 -0
- package/frontend/dist/js/is-DLgJywtZ.js +2 -0
- package/frontend/dist/js/it-lldrM8pE.js +2 -0
- package/frontend/dist/js/ja-Cfe9uZmQ.js +2 -0
- package/frontend/dist/js/ko-BhChO3MQ.js +2 -0
- package/frontend/dist/js/logger-BmpKNkls.js +1 -0
- package/frontend/dist/js/markdown-core-fkbHPd-g.js +16 -0
- package/frontend/dist/js/markdown-math-Ci4QOsf-.js +261 -0
- package/frontend/dist/js/ms-SWtmetXo.js +2 -0
- package/frontend/dist/js/nl-CUO6I3nO.js +2 -0
- package/frontend/dist/js/pl-BUAu96P6.js +2 -0
- package/frontend/dist/js/pt-DCHwoWiE.js +2 -0
- package/frontend/dist/js/{react-vendor-Dh6nfvM2.js → react-vendor-Co1u1rWb.js} +2 -2
- package/frontend/dist/js/ru-Ciz053N7.js +2 -0
- package/frontend/dist/js/sv-D38ZHjv-.js +2 -0
- package/frontend/dist/js/syntax-highlight-D5wpTVgr.js +8 -0
- package/frontend/dist/js/th-D2cAucvn.js +2 -0
- package/frontend/dist/js/tr-Go355oDu.js +2 -0
- package/frontend/dist/js/ui-vendor-BJAeWL11.js +177 -0
- package/frontend/dist/js/uk-cdzjhgya.js +2 -0
- package/frontend/dist/js/useBaseQuery-6OJiOiyu.js +1 -0
- package/frontend/dist/js/useQuery-DfkI6VAv.js +1 -0
- package/frontend/dist/js/utils-BaPYjs_z.js +1 -0
- package/frontend/dist/js/utils-vendor-BX3ZwoIK.js +9 -0
- package/frontend/dist/js/vi-BSOpE423.js +2 -0
- package/frontend/dist/js/zh-BFh6sHZG.js +2 -0
- package/package.json +12 -8
- 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-cOuIFv6G.js +0 -314
- package/frontend/dist/assets/index-tuVjvNle.css +0 -1
- package/frontend/dist/js/ArtifactContainer-DXa2t8GS.js +0 -24
- package/frontend/dist/js/ArtifactDemoPage-Dms7P2gI.js +0 -98
- package/frontend/dist/js/ChatPage-C6hOVIAS.js +0 -15
- package/frontend/dist/js/GalleryPage-CGKPECJV.js +0 -1
- package/frontend/dist/js/ModelsPage-CNB-c-JM.js +0 -1
- package/frontend/dist/js/PersonasPage-hDiS0Rvl.js +0 -13
- package/frontend/dist/js/UserManagementPage-1H9kcHeR.js +0 -1
- package/frontend/dist/js/api-MQ7bONaU.js +0 -3
- package/frontend/dist/js/appStore-BpzQfKOd.js +0 -2
- package/frontend/dist/js/authStore-Cn_WS6Kb.js +0 -2
- package/frontend/dist/js/demoMode-PKHbba1m.js +0 -1
- package/frontend/dist/js/markdown-vendor-DLzJCMRQ.js +0 -22
- package/frontend/dist/js/ui-vendor-ClcVY3m-.js +0 -177
- package/frontend/dist/js/utils-vendor-BCQRX-0Q.js +0 -9
- 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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
logger.error(`Failed to load plugin ${file}:`, error);
|
|
179
222
|
}
|
|
180
223
|
}
|
|
181
224
|
}
|
|
182
225
|
}
|
|
183
226
|
catch (error) {
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
301
|
-
const
|
|
302
|
-
// Find the plugin that supports this model
|
|
303
|
-
for (const plugin of
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
|
618
|
-
const
|
|
619
|
-
|
|
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:
|
|
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
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
499
|
+
// Capability Methods
|
|
937
500
|
// ============================================
|
|
938
|
-
// Get plugin that supports TTS for a specific model
|
|
939
501
|
getPluginForTTS(model) {
|
|
940
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|