libre-webui 0.2.4
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/LICENSE +201 -0
- package/README.md +204 -0
- package/backend/dist/db.d.ts +19 -0
- package/backend/dist/db.d.ts.map +1 -0
- package/backend/dist/db.js +355 -0
- package/backend/dist/db.js.map +1 -0
- package/backend/dist/env.d.ts +2 -0
- package/backend/dist/env.d.ts.map +1 -0
- package/backend/dist/env.js +22 -0
- package/backend/dist/env.js.map +1 -0
- package/backend/dist/index.d.ts +4 -0
- package/backend/dist/index.d.ts.map +1 -0
- package/backend/dist/index.js +751 -0
- package/backend/dist/index.js.map +1 -0
- package/backend/dist/middleware/auth.d.ts +18 -0
- package/backend/dist/middleware/auth.d.ts.map +1 -0
- package/backend/dist/middleware/auth.js +98 -0
- package/backend/dist/middleware/auth.js.map +1 -0
- package/backend/dist/middleware/index.d.ts +5 -0
- package/backend/dist/middleware/index.d.ts.map +1 -0
- package/backend/dist/middleware/index.js +62 -0
- package/backend/dist/middleware/index.js.map +1 -0
- package/backend/dist/models/personaModel.d.ts +37 -0
- package/backend/dist/models/personaModel.d.ts.map +1 -0
- package/backend/dist/models/personaModel.js +269 -0
- package/backend/dist/models/personaModel.js.map +1 -0
- package/backend/dist/models/userModel.d.ts +86 -0
- package/backend/dist/models/userModel.d.ts.map +1 -0
- package/backend/dist/models/userModel.js +212 -0
- package/backend/dist/models/userModel.js.map +1 -0
- package/backend/dist/routes/auth.d.ts +3 -0
- package/backend/dist/routes/auth.d.ts.map +1 -0
- package/backend/dist/routes/auth.js +389 -0
- package/backend/dist/routes/auth.js.map +1 -0
- package/backend/dist/routes/chat.d.ts +3 -0
- package/backend/dist/routes/chat.d.ts.map +1 -0
- package/backend/dist/routes/chat.js +767 -0
- package/backend/dist/routes/chat.js.map +1 -0
- package/backend/dist/routes/documents.d.ts +3 -0
- package/backend/dist/routes/documents.d.ts.map +1 -0
- package/backend/dist/routes/documents.js +244 -0
- package/backend/dist/routes/documents.js.map +1 -0
- package/backend/dist/routes/ollama.d.ts +3 -0
- package/backend/dist/routes/ollama.d.ts.map +1 -0
- package/backend/dist/routes/ollama.js +549 -0
- package/backend/dist/routes/ollama.js.map +1 -0
- package/backend/dist/routes/personas.d.ts +3 -0
- package/backend/dist/routes/personas.d.ts.map +1 -0
- package/backend/dist/routes/personas.js +505 -0
- package/backend/dist/routes/personas.js.map +1 -0
- package/backend/dist/routes/plugins.d.ts +3 -0
- package/backend/dist/routes/plugins.d.ts.map +1 -0
- package/backend/dist/routes/plugins.js +417 -0
- package/backend/dist/routes/plugins.js.map +1 -0
- package/backend/dist/routes/preferences.d.ts +3 -0
- package/backend/dist/routes/preferences.d.ts.map +1 -0
- package/backend/dist/routes/preferences.js +303 -0
- package/backend/dist/routes/preferences.js.map +1 -0
- package/backend/dist/routes/tts.d.ts +3 -0
- package/backend/dist/routes/tts.d.ts.map +1 -0
- package/backend/dist/routes/tts.js +304 -0
- package/backend/dist/routes/tts.js.map +1 -0
- package/backend/dist/routes/users.d.ts +3 -0
- package/backend/dist/routes/users.d.ts.map +1 -0
- package/backend/dist/routes/users.js +246 -0
- package/backend/dist/routes/users.js.map +1 -0
- package/backend/dist/services/authService.d.ts +51 -0
- package/backend/dist/services/authService.d.ts.map +1 -0
- package/backend/dist/services/authService.js +153 -0
- package/backend/dist/services/authService.js.map +1 -0
- package/backend/dist/services/chatService.d.ts +52 -0
- package/backend/dist/services/chatService.d.ts.map +1 -0
- package/backend/dist/services/chatService.js +645 -0
- package/backend/dist/services/chatService.js.map +1 -0
- package/backend/dist/services/documentService.d.ts +34 -0
- package/backend/dist/services/documentService.d.ts.map +1 -0
- package/backend/dist/services/documentService.js +428 -0
- package/backend/dist/services/documentService.js.map +1 -0
- package/backend/dist/services/encryptionService.d.ts +62 -0
- package/backend/dist/services/encryptionService.d.ts.map +1 -0
- package/backend/dist/services/encryptionService.js +284 -0
- package/backend/dist/services/encryptionService.js.map +1 -0
- package/backend/dist/services/memoryService.d.ts +140 -0
- package/backend/dist/services/memoryService.d.ts.map +1 -0
- package/backend/dist/services/memoryService.js +867 -0
- package/backend/dist/services/memoryService.js.map +1 -0
- package/backend/dist/services/mutationEngineService.d.ts +49 -0
- package/backend/dist/services/mutationEngineService.d.ts.map +1 -0
- package/backend/dist/services/mutationEngineService.js +432 -0
- package/backend/dist/services/mutationEngineService.js.map +1 -0
- package/backend/dist/services/ollamaService.d.ts +55 -0
- package/backend/dist/services/ollamaService.d.ts.map +1 -0
- package/backend/dist/services/ollamaService.js +450 -0
- package/backend/dist/services/ollamaService.js.map +1 -0
- package/backend/dist/services/personaService.d.ts +67 -0
- package/backend/dist/services/personaService.d.ts.map +1 -0
- package/backend/dist/services/personaService.js +373 -0
- package/backend/dist/services/personaService.js.map +1 -0
- package/backend/dist/services/pluginService.d.ts +42 -0
- package/backend/dist/services/pluginService.d.ts.map +1 -0
- package/backend/dist/services/pluginService.js +961 -0
- package/backend/dist/services/pluginService.js.map +1 -0
- package/backend/dist/services/preferencesService.d.ts +35 -0
- package/backend/dist/services/preferencesService.d.ts.map +1 -0
- package/backend/dist/services/preferencesService.js +255 -0
- package/backend/dist/services/preferencesService.js.map +1 -0
- package/backend/dist/services/simpleGitHubOAuth.d.ts +48 -0
- package/backend/dist/services/simpleGitHubOAuth.d.ts.map +1 -0
- package/backend/dist/services/simpleGitHubOAuth.js +203 -0
- package/backend/dist/services/simpleGitHubOAuth.js.map +1 -0
- package/backend/dist/services/simpleHuggingFaceOAuth.d.ts +43 -0
- package/backend/dist/services/simpleHuggingFaceOAuth.d.ts.map +1 -0
- package/backend/dist/services/simpleHuggingFaceOAuth.js +159 -0
- package/backend/dist/services/simpleHuggingFaceOAuth.js.map +1 -0
- package/backend/dist/services/userService.d.ts +1 -0
- package/backend/dist/services/userService.d.ts.map +1 -0
- package/backend/dist/services/userService.js +18 -0
- package/backend/dist/services/userService.js.map +1 -0
- package/backend/dist/storage.d.ts +55 -0
- package/backend/dist/storage.d.ts.map +1 -0
- package/backend/dist/storage.js +741 -0
- package/backend/dist/storage.js.map +1 -0
- package/backend/dist/test-encryption.d.ts +2 -0
- package/backend/dist/test-encryption.d.ts.map +1 -0
- package/backend/dist/test-encryption.js +64 -0
- package/backend/dist/test-encryption.js.map +1 -0
- package/backend/dist/types/index.d.ts +523 -0
- package/backend/dist/types/index.d.ts.map +1 -0
- package/backend/dist/types/index.js +31 -0
- package/backend/dist/types/index.js.map +1 -0
- package/backend/dist/utils/generationUtils.d.ts +10 -0
- package/backend/dist/utils/generationUtils.d.ts.map +1 -0
- package/backend/dist/utils/generationUtils.js +49 -0
- package/backend/dist/utils/generationUtils.js.map +1 -0
- package/backend/dist/utils/hash.d.ts +29 -0
- package/backend/dist/utils/hash.d.ts.map +1 -0
- package/backend/dist/utils/hash.js +73 -0
- package/backend/dist/utils/hash.js.map +1 -0
- package/backend/dist/utils/jwt.d.ts +37 -0
- package/backend/dist/utils/jwt.d.ts.map +1 -0
- package/backend/dist/utils/jwt.js +86 -0
- package/backend/dist/utils/jwt.js.map +1 -0
- package/bin/cli.js +150 -0
- package/electron/main.js +322 -0
- package/frontend/dist/_redirects +1 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/frontend/dist/assets/index-CRQkB7Wz.js +3 -0
- package/frontend/dist/css/index-B1OjddR-.css +1 -0
- package/frontend/dist/favicon-dark.png +0 -0
- package/frontend/dist/favicon-light.png +0 -0
- package/frontend/dist/index.html +23 -0
- package/frontend/dist/js/ArtifactContainer-c_oi7XMs.js +23 -0
- package/frontend/dist/js/ArtifactDemoPage-CdfwJVXu.js +98 -0
- package/frontend/dist/js/ChatPage-CyotkmS0.js +281 -0
- package/frontend/dist/js/ModelsPage-DNaziPHc.js +2 -0
- package/frontend/dist/js/PersonasPage-DcnbJf8Q.js +13 -0
- package/frontend/dist/js/UserManagementPage-DtTf92dS.js +1 -0
- package/frontend/dist/js/markdown-vendor-D-79K2xZ.js +22 -0
- package/frontend/dist/js/react-vendor-N--QU9DW.js +8 -0
- package/frontend/dist/js/router-vendor-B-t91v39.js +3 -0
- package/frontend/dist/js/ui-vendor-VxSCY_bv.js +177 -0
- package/frontend/dist/js/utils-vendor-DNzxLBGx.js +6 -0
- package/frontend/dist/logo-dark.png +0 -0
- package/frontend/dist/logo-light.png +0 -0
- package/frontend/dist/logo.svg +14 -0
- package/package.json +128 -0
- package/plugins/anthropic.json +25 -0
- package/plugins/elevenlabs.json +58 -0
- package/plugins/gemini.json +57 -0
- package/plugins/github.json +23 -0
- package/plugins/groq.json +25 -0
- package/plugins/mistral.json +73 -0
- package/plugins/openai-tts.json +38 -0
- package/plugins/openai.json +132 -0
- package/plugins/openrouter.json +353 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Libre WebUI
|
|
3
|
+
* Copyright (C) 2025 Kroonen AI, Inc.
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at:
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import sanitize from 'sanitize-filename';
|
|
20
|
+
import axios from 'axios';
|
|
21
|
+
class PluginService {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.activePluginIds = new Set();
|
|
24
|
+
this.pluginsDir = path.join(process.cwd(), 'plugins');
|
|
25
|
+
this.ensurePluginsDirectory();
|
|
26
|
+
this.loadActivePlugins();
|
|
27
|
+
}
|
|
28
|
+
ensurePluginsDirectory() {
|
|
29
|
+
if (!fs.existsSync(this.pluginsDir)) {
|
|
30
|
+
fs.mkdirSync(this.pluginsDir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
loadActivePlugins() {
|
|
34
|
+
const statusFile = path.join(this.pluginsDir, '.status.json');
|
|
35
|
+
if (fs.existsSync(statusFile)) {
|
|
36
|
+
try {
|
|
37
|
+
const status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
|
|
38
|
+
if (Array.isArray(status.activePlugins)) {
|
|
39
|
+
this.activePluginIds = new Set(status.activePlugins);
|
|
40
|
+
}
|
|
41
|
+
else if (status.activePlugin) {
|
|
42
|
+
// Legacy support for single active plugin
|
|
43
|
+
this.activePluginIds = new Set([status.activePlugin]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
console.error('Failed to load plugin status:', error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
saveActivePlugins() {
|
|
52
|
+
const statusFile = path.join(this.pluginsDir, '.status.json');
|
|
53
|
+
const status = {
|
|
54
|
+
activePlugins: Array.from(this.activePluginIds),
|
|
55
|
+
lastUpdated: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
fs.writeFileSync(statusFile, JSON.stringify(status, null, 2));
|
|
58
|
+
}
|
|
59
|
+
// List all installed plugins
|
|
60
|
+
getAllPlugins() {
|
|
61
|
+
const plugins = [];
|
|
62
|
+
try {
|
|
63
|
+
const files = fs.readdirSync(this.pluginsDir);
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
if (file.endsWith('.json') && !file.startsWith('.')) {
|
|
66
|
+
try {
|
|
67
|
+
const filePath = path.join(this.pluginsDir, file);
|
|
68
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
69
|
+
const plugin = JSON.parse(content);
|
|
70
|
+
// Validate plugin structure
|
|
71
|
+
if (this.validatePlugin(plugin)) {
|
|
72
|
+
plugin.active = this.activePluginIds.has(plugin.id);
|
|
73
|
+
plugins.push(plugin);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error(`Failed to load plugin ${file}:`, error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
console.error('Failed to read plugins directory:', error);
|
|
84
|
+
}
|
|
85
|
+
return plugins;
|
|
86
|
+
}
|
|
87
|
+
// Get a specific plugin by ID
|
|
88
|
+
getPlugin(id) {
|
|
89
|
+
// Sanitize the ID to prevent path traversal
|
|
90
|
+
const sanitizedId = sanitize(id);
|
|
91
|
+
if (!sanitizedId || sanitizedId !== id) {
|
|
92
|
+
console.error('Invalid plugin ID provided:', id);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const filePath = path.resolve(this.pluginsDir, `${sanitizedId}.json`);
|
|
96
|
+
// Ensure the file path is within the plugins directory
|
|
97
|
+
if (!filePath.startsWith(path.resolve(this.pluginsDir)) ||
|
|
98
|
+
!fs.existsSync(filePath)) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
103
|
+
const plugin = JSON.parse(content);
|
|
104
|
+
if (this.validatePlugin(plugin)) {
|
|
105
|
+
plugin.active = this.activePluginIds.has(plugin.id);
|
|
106
|
+
return plugin;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error('Failed to load plugin %s:', sanitizedId, error);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
// Install or update a plugin
|
|
115
|
+
installPlugin(pluginData) {
|
|
116
|
+
if (!this.validatePlugin(pluginData)) {
|
|
117
|
+
throw new Error('Invalid plugin structure');
|
|
118
|
+
}
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const plugin = {
|
|
121
|
+
...pluginData,
|
|
122
|
+
created_at: pluginData.created_at || now,
|
|
123
|
+
updated_at: now,
|
|
124
|
+
active: false,
|
|
125
|
+
};
|
|
126
|
+
const filePath = path.join(this.pluginsDir, `${plugin.id}.json`);
|
|
127
|
+
fs.writeFileSync(filePath, JSON.stringify(plugin, null, 2));
|
|
128
|
+
return plugin;
|
|
129
|
+
}
|
|
130
|
+
// Delete a plugin
|
|
131
|
+
deletePlugin(id) {
|
|
132
|
+
// Validate the ID parameter using a strict pattern
|
|
133
|
+
const idPattern = /^[a-zA-Z0-9_-]+$/;
|
|
134
|
+
if (!idPattern.test(id)) {
|
|
135
|
+
console.error('Invalid plugin ID format:', id);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
// Sanitize the ID to prevent path traversal
|
|
139
|
+
const sanitizedId = sanitize(id);
|
|
140
|
+
if (!sanitizedId || sanitizedId !== id) {
|
|
141
|
+
console.error('Plugin ID failed sanitization:', id);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const filePath = path.resolve(this.pluginsDir, `${sanitizedId}.json`);
|
|
145
|
+
// Ensure the file path is within the plugins directory
|
|
146
|
+
if (!filePath.startsWith(path.resolve(this.pluginsDir)) ||
|
|
147
|
+
!fs.existsSync(filePath)) {
|
|
148
|
+
console.error('File path is invalid or does not exist:', filePath);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
fs.unlinkSync(filePath);
|
|
153
|
+
// If this was an active plugin, deactivate it
|
|
154
|
+
if (this.activePluginIds.has(id)) {
|
|
155
|
+
this.activePluginIds.delete(id);
|
|
156
|
+
this.saveActivePlugins();
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
console.error('Failed to delete plugin %s:', sanitizedId, error);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Activate a plugin
|
|
166
|
+
activatePlugin(id) {
|
|
167
|
+
const plugin = this.getPlugin(id);
|
|
168
|
+
if (!plugin) {
|
|
169
|
+
throw new Error('Plugin not found');
|
|
170
|
+
}
|
|
171
|
+
this.activePluginIds.add(id);
|
|
172
|
+
this.saveActivePlugins();
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
// Deactivate a specific plugin
|
|
176
|
+
deactivatePlugin(id) {
|
|
177
|
+
if (id) {
|
|
178
|
+
this.activePluginIds.delete(id);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// Legacy: deactivate all plugins
|
|
182
|
+
this.activePluginIds.clear();
|
|
183
|
+
}
|
|
184
|
+
this.saveActivePlugins();
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
// Get the active plugin for a specific model
|
|
188
|
+
getActivePluginForModel(model) {
|
|
189
|
+
console.log(`[DEBUG] Looking for plugin for model: ${model}`);
|
|
190
|
+
// Get all available plugins (not just active ones)
|
|
191
|
+
const allPlugins = this.getAllPlugins();
|
|
192
|
+
console.log(`[DEBUG] Available plugins:`, allPlugins.map(p => p.id));
|
|
193
|
+
// Find the plugin that supports this model
|
|
194
|
+
for (const plugin of allPlugins) {
|
|
195
|
+
console.log(`[DEBUG] Checking plugin ${plugin.id} with model_map:`, plugin.model_map);
|
|
196
|
+
if (plugin.model_map.includes(model)) {
|
|
197
|
+
console.log(`[DEBUG] Found plugin ${plugin.id} for model ${model}`);
|
|
198
|
+
// Check if we have the required API key
|
|
199
|
+
const apiKey = process.env[plugin.auth.key_env];
|
|
200
|
+
if (!apiKey) {
|
|
201
|
+
console.log(`[DEBUG] Plugin ${plugin.id} found but API key ${plugin.auth.key_env} not set`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
return plugin;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
console.log(`[DEBUG] No plugin found for model: ${model}`);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
// Get all currently active plugins
|
|
211
|
+
getActivePlugins() {
|
|
212
|
+
const allPlugins = this.getAllPlugins();
|
|
213
|
+
console.log('All plugins:', allPlugins.map(p => ({ id: p.id, active: p.active })));
|
|
214
|
+
console.log('Active plugin IDs in memory:', Array.from(this.activePluginIds));
|
|
215
|
+
const activePlugins = allPlugins.filter(plugin => this.activePluginIds.has(plugin.id));
|
|
216
|
+
console.log('Filtered active plugins:', activePlugins.map(p => p.id));
|
|
217
|
+
return activePlugins;
|
|
218
|
+
}
|
|
219
|
+
// Legacy method for backward compatibility - returns first active plugin
|
|
220
|
+
getActivePlugin() {
|
|
221
|
+
const activePlugins = this.getActivePlugins();
|
|
222
|
+
return activePlugins.length > 0 ? activePlugins[0] : null;
|
|
223
|
+
}
|
|
224
|
+
// Get plugin status
|
|
225
|
+
getPluginStatus() {
|
|
226
|
+
const plugins = this.getAllPlugins();
|
|
227
|
+
return plugins.map(plugin => ({
|
|
228
|
+
id: plugin.id,
|
|
229
|
+
active: plugin.active || false,
|
|
230
|
+
available: true, // Could be enhanced to check endpoint availability
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
// Execute a chat request through the active plugin
|
|
234
|
+
async executePluginRequest(model, messages, options = {}) {
|
|
235
|
+
// Validate model parameter to prevent SSRF attacks
|
|
236
|
+
if (!model || typeof model !== 'string') {
|
|
237
|
+
throw new Error('Invalid model parameter: must be a non-empty string');
|
|
238
|
+
}
|
|
239
|
+
// Sanitize model parameter - only allow alphanumeric, hyphens, underscores, colons, and dots
|
|
240
|
+
const modelPattern = /^[a-zA-Z0-9\-_:.]+$/;
|
|
241
|
+
if (!modelPattern.test(model)) {
|
|
242
|
+
throw new Error(`Invalid model parameter: ${model} contains invalid characters`);
|
|
243
|
+
}
|
|
244
|
+
// Prevent path traversal and other malicious patterns
|
|
245
|
+
if (model.includes('..') || model.includes('//') || model.includes('\\')) {
|
|
246
|
+
throw new Error(`Invalid model parameter: ${model} contains invalid patterns`);
|
|
247
|
+
}
|
|
248
|
+
const activePlugin = this.getActivePluginForModel(model);
|
|
249
|
+
if (!activePlugin) {
|
|
250
|
+
throw new Error(`No active plugin found for model: ${model}`);
|
|
251
|
+
}
|
|
252
|
+
// Additional validation: ensure the model is in the plugin's allowed model_map
|
|
253
|
+
if (!activePlugin.model_map.includes(model)) {
|
|
254
|
+
throw new Error(`Model ${model} is not supported by plugin ${activePlugin.id}`);
|
|
255
|
+
}
|
|
256
|
+
if (!activePlugin) {
|
|
257
|
+
throw new Error(`No active plugin found for model: ${model}`);
|
|
258
|
+
}
|
|
259
|
+
// Get API key from environment
|
|
260
|
+
const apiKey = process.env[activePlugin.auth.key_env];
|
|
261
|
+
if (!apiKey) {
|
|
262
|
+
throw new Error(`API key not found in environment variable: ${activePlugin.auth.key_env}`);
|
|
263
|
+
}
|
|
264
|
+
// Prepare headers
|
|
265
|
+
const headers = {
|
|
266
|
+
'Content-Type': 'application/json',
|
|
267
|
+
};
|
|
268
|
+
const authValue = activePlugin.auth.prefix
|
|
269
|
+
? `${activePlugin.auth.prefix}${apiKey}`
|
|
270
|
+
: apiKey;
|
|
271
|
+
headers[activePlugin.auth.header] = authValue;
|
|
272
|
+
// Prepare request payload based on plugin type
|
|
273
|
+
let payload;
|
|
274
|
+
if (activePlugin.id === 'anthropic') {
|
|
275
|
+
// Anthropic-specific payload format
|
|
276
|
+
// Separate system messages from user/assistant messages
|
|
277
|
+
const systemMessages = messages.filter(msg => msg.role === 'system');
|
|
278
|
+
const nonSystemMessages = messages.filter(msg => msg.role !== 'system');
|
|
279
|
+
// Convert messages to Anthropic format with image support
|
|
280
|
+
const anthropicMessages = nonSystemMessages.map(msg => {
|
|
281
|
+
// Check if message has images
|
|
282
|
+
if (msg.images && msg.images.length > 0) {
|
|
283
|
+
// Anthropic format: content is an array of content blocks
|
|
284
|
+
const contentBlocks = [];
|
|
285
|
+
// Add images first
|
|
286
|
+
for (const image of msg.images) {
|
|
287
|
+
// Extract base64 data and media type from data URL
|
|
288
|
+
let base64Data = image;
|
|
289
|
+
let mediaType = 'image/jpeg'; // Default
|
|
290
|
+
if (image.startsWith('data:')) {
|
|
291
|
+
const match = image.match(/^data:([^;]+);base64,(.+)$/);
|
|
292
|
+
if (match) {
|
|
293
|
+
mediaType = match[1];
|
|
294
|
+
base64Data = match[2];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
contentBlocks.push({
|
|
298
|
+
type: 'image',
|
|
299
|
+
source: {
|
|
300
|
+
type: 'base64',
|
|
301
|
+
media_type: mediaType,
|
|
302
|
+
data: base64Data,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
// Add text content
|
|
307
|
+
if (msg.content) {
|
|
308
|
+
contentBlocks.push({
|
|
309
|
+
type: 'text',
|
|
310
|
+
text: msg.content,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
role: msg.role,
|
|
315
|
+
content: contentBlocks,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// No images - simple text content
|
|
319
|
+
return {
|
|
320
|
+
role: msg.role,
|
|
321
|
+
content: msg.content,
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
payload = {
|
|
325
|
+
model,
|
|
326
|
+
messages: anthropicMessages,
|
|
327
|
+
max_tokens: options.num_predict && options.num_predict !== -1
|
|
328
|
+
? options.num_predict
|
|
329
|
+
: 1024,
|
|
330
|
+
temperature: options.temperature || 0.7,
|
|
331
|
+
top_p: options.top_p,
|
|
332
|
+
stop_sequences: options.stop,
|
|
333
|
+
stream: false,
|
|
334
|
+
};
|
|
335
|
+
// Add system message as top-level parameter if present
|
|
336
|
+
if (systemMessages.length > 0) {
|
|
337
|
+
payload.system = systemMessages.map(msg => msg.content).join('\n');
|
|
338
|
+
}
|
|
339
|
+
// Add required anthropic-version header
|
|
340
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
341
|
+
}
|
|
342
|
+
else if (activePlugin.id === 'gemini') {
|
|
343
|
+
// Gemini-specific payload format with image support
|
|
344
|
+
const lastMessage = messages[messages.length - 1];
|
|
345
|
+
const parts = [];
|
|
346
|
+
// Add images if present
|
|
347
|
+
if (lastMessage?.images && lastMessage.images.length > 0) {
|
|
348
|
+
for (const image of lastMessage.images) {
|
|
349
|
+
let base64Data = image;
|
|
350
|
+
let mimeType = 'image/jpeg';
|
|
351
|
+
if (image.startsWith('data:')) {
|
|
352
|
+
const match = image.match(/^data:([^;]+);base64,(.+)$/);
|
|
353
|
+
if (match) {
|
|
354
|
+
mimeType = match[1];
|
|
355
|
+
base64Data = match[2];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
parts.push({
|
|
359
|
+
inline_data: {
|
|
360
|
+
mime_type: mimeType,
|
|
361
|
+
data: base64Data,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Add text content
|
|
367
|
+
if (lastMessage?.content) {
|
|
368
|
+
parts.push({ text: lastMessage.content });
|
|
369
|
+
}
|
|
370
|
+
payload = {
|
|
371
|
+
contents: [{ parts }],
|
|
372
|
+
generationConfig: {
|
|
373
|
+
temperature: options.temperature || 0.7,
|
|
374
|
+
maxOutputTokens: options.num_predict && options.num_predict !== -1
|
|
375
|
+
? options.num_predict
|
|
376
|
+
: 1024,
|
|
377
|
+
topP: options.top_p,
|
|
378
|
+
stopSequences: options.stop,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
// Default OpenAI-compatible format with image support
|
|
384
|
+
const openaiMessages = messages.map(msg => {
|
|
385
|
+
// Check if message has images (OpenAI vision format)
|
|
386
|
+
if (msg.images && msg.images.length > 0) {
|
|
387
|
+
const content = [];
|
|
388
|
+
// Add images
|
|
389
|
+
for (const image of msg.images) {
|
|
390
|
+
// OpenAI expects data URLs or regular URLs
|
|
391
|
+
const imageUrl = image.startsWith('data:')
|
|
392
|
+
? image
|
|
393
|
+
: `data:image/jpeg;base64,${image}`;
|
|
394
|
+
content.push({
|
|
395
|
+
type: 'image_url',
|
|
396
|
+
image_url: { url: imageUrl },
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// Add text
|
|
400
|
+
if (msg.content) {
|
|
401
|
+
content.push({ type: 'text', text: msg.content });
|
|
402
|
+
}
|
|
403
|
+
return { role: msg.role, content };
|
|
404
|
+
}
|
|
405
|
+
return { role: msg.role, content: msg.content };
|
|
406
|
+
});
|
|
407
|
+
payload = {
|
|
408
|
+
model,
|
|
409
|
+
messages: openaiMessages,
|
|
410
|
+
temperature: options.temperature || 0.7,
|
|
411
|
+
max_tokens: options.num_predict === -1 ? undefined : options.num_predict,
|
|
412
|
+
top_p: options.top_p,
|
|
413
|
+
stop: options.stop,
|
|
414
|
+
stream: false,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
// Process endpoint template - replace {model} with actual model name
|
|
418
|
+
// Final validation before URL construction to prevent SSRF
|
|
419
|
+
const sanitizedModel = encodeURIComponent(model);
|
|
420
|
+
const processedEndpoint = activePlugin.endpoint.replace('{model}', sanitizedModel);
|
|
421
|
+
// Validate the final endpoint URL
|
|
422
|
+
try {
|
|
423
|
+
const url = new URL(processedEndpoint);
|
|
424
|
+
// Allow HTTP only for localhost/127.0.0.1 (safe for local development)
|
|
425
|
+
const isLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
426
|
+
if (url.protocol !== 'https:' && !isLocalhost) {
|
|
427
|
+
throw new Error(`Insecure endpoint protocol: ${url.protocol}. Only HTTPS is allowed for remote endpoints. ` +
|
|
428
|
+
`(HTTP is permitted only for localhost during local development)`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch (_error) {
|
|
432
|
+
throw new Error(`Invalid endpoint URL constructed: ${processedEndpoint}`);
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const response = await axios.post(processedEndpoint, payload, {
|
|
436
|
+
headers,
|
|
437
|
+
timeout: 60000, // 60 second timeout
|
|
438
|
+
});
|
|
439
|
+
// Handle different response formats
|
|
440
|
+
if (activePlugin.id === 'anthropic') {
|
|
441
|
+
return this.convertAnthropicResponse(response.data, model);
|
|
442
|
+
}
|
|
443
|
+
else if (activePlugin.id === 'gemini') {
|
|
444
|
+
return this.convertGeminiResponse(response.data, model);
|
|
445
|
+
}
|
|
446
|
+
// Default to OpenAI format
|
|
447
|
+
return response.data;
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
console.error(`Plugin request failed for ${activePlugin.id}:`, error);
|
|
451
|
+
if (error && typeof error === 'object' && 'response' in error) {
|
|
452
|
+
const axiosError = error;
|
|
453
|
+
throw new Error(`Plugin API error: ${axiosError.response.status} - ${axiosError.response.data?.error?.message || axiosError.response.statusText}`);
|
|
454
|
+
}
|
|
455
|
+
else if (error && typeof error === 'object' && 'request' in error) {
|
|
456
|
+
throw new Error(`Plugin connection error: Unable to reach ${processedEndpoint}`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
460
|
+
throw new Error(`Plugin error: ${errorMessage}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Convert Anthropic response format to OpenAI format
|
|
465
|
+
convertAnthropicResponse(anthropicResponse, model) {
|
|
466
|
+
const id = typeof anthropicResponse.id === 'string'
|
|
467
|
+
? anthropicResponse.id
|
|
468
|
+
: `chatcmpl-${Date.now()}`;
|
|
469
|
+
// Map Anthropic stop reasons to OpenAI format
|
|
470
|
+
const stopReasonMap = {
|
|
471
|
+
end_turn: 'stop',
|
|
472
|
+
max_tokens: 'length',
|
|
473
|
+
stop_sequence: 'stop',
|
|
474
|
+
tool_use: 'tool_calls',
|
|
475
|
+
};
|
|
476
|
+
const stopReason = typeof anthropicResponse.stop_reason === 'string'
|
|
477
|
+
? stopReasonMap[anthropicResponse.stop_reason] || 'stop'
|
|
478
|
+
: 'stop';
|
|
479
|
+
let content = '';
|
|
480
|
+
// Anthropic returns content as an array of content blocks
|
|
481
|
+
if (Array.isArray(anthropicResponse.content)) {
|
|
482
|
+
for (const block of anthropicResponse.content) {
|
|
483
|
+
if (block &&
|
|
484
|
+
typeof block === 'object' &&
|
|
485
|
+
'type' in block &&
|
|
486
|
+
block.type === 'text' &&
|
|
487
|
+
'text' in block &&
|
|
488
|
+
typeof block.text === 'string') {
|
|
489
|
+
content += block.text;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
let usage;
|
|
494
|
+
if (anthropicResponse.usage &&
|
|
495
|
+
typeof anthropicResponse.usage === 'object' &&
|
|
496
|
+
anthropicResponse.usage !== null) {
|
|
497
|
+
const usageObj = anthropicResponse.usage;
|
|
498
|
+
const inputTokens = typeof usageObj.input_tokens === 'number' ? usageObj.input_tokens : 0;
|
|
499
|
+
const outputTokens = typeof usageObj.output_tokens === 'number' ? usageObj.output_tokens : 0;
|
|
500
|
+
usage = {
|
|
501
|
+
prompt_tokens: inputTokens,
|
|
502
|
+
completion_tokens: outputTokens,
|
|
503
|
+
total_tokens: inputTokens + outputTokens,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
id,
|
|
508
|
+
object: 'chat.completion',
|
|
509
|
+
created: Math.floor(Date.now() / 1000),
|
|
510
|
+
model,
|
|
511
|
+
choices: [
|
|
512
|
+
{
|
|
513
|
+
index: 0,
|
|
514
|
+
message: {
|
|
515
|
+
role: 'assistant',
|
|
516
|
+
content,
|
|
517
|
+
},
|
|
518
|
+
finish_reason: stopReason,
|
|
519
|
+
},
|
|
520
|
+
],
|
|
521
|
+
usage,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
// Convert Gemini response format to OpenAI format
|
|
525
|
+
convertGeminiResponse(geminiResponse, model) {
|
|
526
|
+
const id = `chatcmpl-${Date.now()}`;
|
|
527
|
+
let content = '';
|
|
528
|
+
let finishReason = 'stop';
|
|
529
|
+
// Gemini returns candidates array
|
|
530
|
+
if (Array.isArray(geminiResponse.candidates)) {
|
|
531
|
+
const candidate = geminiResponse.candidates[0];
|
|
532
|
+
if (candidate && typeof candidate === 'object') {
|
|
533
|
+
const candidateObj = candidate;
|
|
534
|
+
// Extract content from parts
|
|
535
|
+
if (candidateObj.content && typeof candidateObj.content === 'object') {
|
|
536
|
+
const contentObj = candidateObj.content;
|
|
537
|
+
if (Array.isArray(contentObj.parts)) {
|
|
538
|
+
for (const part of contentObj.parts) {
|
|
539
|
+
if (part &&
|
|
540
|
+
typeof part === 'object' &&
|
|
541
|
+
'text' in part &&
|
|
542
|
+
typeof part.text === 'string') {
|
|
543
|
+
content += part.text;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Map Gemini finish reason to OpenAI format
|
|
549
|
+
if (typeof candidateObj.finishReason === 'string') {
|
|
550
|
+
const finishReasonMap = {
|
|
551
|
+
STOP: 'stop',
|
|
552
|
+
MAX_TOKENS: 'length',
|
|
553
|
+
SAFETY: 'content_filter',
|
|
554
|
+
RECITATION: 'content_filter',
|
|
555
|
+
OTHER: 'stop',
|
|
556
|
+
};
|
|
557
|
+
finishReason = finishReasonMap[candidateObj.finishReason] || 'stop';
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Extract usage if available
|
|
562
|
+
let usage;
|
|
563
|
+
if (geminiResponse.usageMetadata &&
|
|
564
|
+
typeof geminiResponse.usageMetadata === 'object') {
|
|
565
|
+
const usageObj = geminiResponse.usageMetadata;
|
|
566
|
+
const promptTokens = typeof usageObj.promptTokenCount === 'number'
|
|
567
|
+
? usageObj.promptTokenCount
|
|
568
|
+
: 0;
|
|
569
|
+
const completionTokens = typeof usageObj.candidatesTokenCount === 'number'
|
|
570
|
+
? usageObj.candidatesTokenCount
|
|
571
|
+
: 0;
|
|
572
|
+
usage = {
|
|
573
|
+
prompt_tokens: promptTokens,
|
|
574
|
+
completion_tokens: completionTokens,
|
|
575
|
+
total_tokens: promptTokens + completionTokens,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
id,
|
|
580
|
+
object: 'chat.completion',
|
|
581
|
+
created: Math.floor(Date.now() / 1000),
|
|
582
|
+
model,
|
|
583
|
+
choices: [
|
|
584
|
+
{
|
|
585
|
+
index: 0,
|
|
586
|
+
message: {
|
|
587
|
+
role: 'assistant',
|
|
588
|
+
content,
|
|
589
|
+
},
|
|
590
|
+
finish_reason: finishReason,
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
usage,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
// Validate plugin structure
|
|
597
|
+
validatePlugin(plugin) {
|
|
598
|
+
return (typeof plugin === 'object' &&
|
|
599
|
+
plugin !== null &&
|
|
600
|
+
typeof plugin.id === 'string' &&
|
|
601
|
+
typeof plugin.name === 'string' &&
|
|
602
|
+
typeof plugin.type === 'string' &&
|
|
603
|
+
typeof plugin.endpoint === 'string' &&
|
|
604
|
+
typeof plugin.auth === 'object' &&
|
|
605
|
+
plugin.auth !== null &&
|
|
606
|
+
typeof plugin.auth.header === 'string' &&
|
|
607
|
+
typeof plugin.auth.key_env === 'string' &&
|
|
608
|
+
(plugin.auth
|
|
609
|
+
.prefix === undefined ||
|
|
610
|
+
typeof plugin.auth.prefix === 'string') &&
|
|
611
|
+
Array.isArray(plugin.model_map) &&
|
|
612
|
+
plugin.model_map.length > 0);
|
|
613
|
+
}
|
|
614
|
+
// Export plugin to JSON
|
|
615
|
+
exportPlugin(id) {
|
|
616
|
+
return this.getPlugin(id);
|
|
617
|
+
}
|
|
618
|
+
// Import plugin from JSON data
|
|
619
|
+
importPlugin(pluginData) {
|
|
620
|
+
// Validate and clean the plugin data
|
|
621
|
+
if (!this.validatePlugin(pluginData)) {
|
|
622
|
+
throw new Error('Invalid plugin data');
|
|
623
|
+
}
|
|
624
|
+
// Check if plugin already exists
|
|
625
|
+
const existingPlugin = this.getPlugin(pluginData.id);
|
|
626
|
+
if (existingPlugin) {
|
|
627
|
+
throw new Error(`Plugin with ID ${pluginData.id} already exists`);
|
|
628
|
+
}
|
|
629
|
+
return this.installPlugin(pluginData);
|
|
630
|
+
}
|
|
631
|
+
// ============================================
|
|
632
|
+
// TTS (Text-to-Speech) Methods
|
|
633
|
+
// ============================================
|
|
634
|
+
// Get plugin that supports TTS for a specific model
|
|
635
|
+
getPluginForTTS(model) {
|
|
636
|
+
console.log(`[DEBUG] Looking for TTS plugin for model: ${model}`);
|
|
637
|
+
const allPlugins = this.getAllPlugins();
|
|
638
|
+
for (const plugin of allPlugins) {
|
|
639
|
+
// Check if plugin has TTS capability
|
|
640
|
+
if (plugin.capabilities?.tts) {
|
|
641
|
+
const ttsCapability = plugin.capabilities.tts;
|
|
642
|
+
if (ttsCapability.model_map.includes(model)) {
|
|
643
|
+
console.log(`[DEBUG] Found TTS plugin ${plugin.id} for model ${model}`);
|
|
644
|
+
// Check if we have the required API key
|
|
645
|
+
const apiKey = process.env[plugin.auth.key_env];
|
|
646
|
+
if (!apiKey) {
|
|
647
|
+
console.log(`[DEBUG] Plugin ${plugin.id} found but API key ${plugin.auth.key_env} not set`);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
return plugin;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// Also check primary type for backward compatibility with TTS-only plugins
|
|
654
|
+
if (plugin.type === 'tts' && plugin.model_map.includes(model)) {
|
|
655
|
+
console.log(`[DEBUG] Found TTS-type plugin ${plugin.id} for model ${model}`);
|
|
656
|
+
const apiKey = process.env[plugin.auth.key_env];
|
|
657
|
+
if (!apiKey) {
|
|
658
|
+
console.log(`[DEBUG] Plugin ${plugin.id} found but API key ${plugin.auth.key_env} not set`);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
return plugin;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
console.log(`[DEBUG] No TTS plugin found for model: ${model}`);
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
// Get all available TTS models from all plugins
|
|
668
|
+
getAvailableTTSModels() {
|
|
669
|
+
const models = [];
|
|
670
|
+
const allPlugins = this.getAllPlugins();
|
|
671
|
+
for (const plugin of allPlugins) {
|
|
672
|
+
// Check capabilities-based TTS
|
|
673
|
+
if (plugin.capabilities?.tts) {
|
|
674
|
+
const ttsCapability = plugin.capabilities.tts;
|
|
675
|
+
// Check if API key is available
|
|
676
|
+
const apiKey = process.env[plugin.auth.key_env];
|
|
677
|
+
if (apiKey) {
|
|
678
|
+
for (const model of ttsCapability.model_map) {
|
|
679
|
+
models.push({
|
|
680
|
+
model,
|
|
681
|
+
plugin: plugin.id,
|
|
682
|
+
config: ttsCapability.config,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// Check primary type for TTS-only plugins
|
|
688
|
+
if (plugin.type === 'tts') {
|
|
689
|
+
const apiKey = process.env[plugin.auth.key_env];
|
|
690
|
+
if (apiKey) {
|
|
691
|
+
for (const model of plugin.model_map) {
|
|
692
|
+
models.push({
|
|
693
|
+
model,
|
|
694
|
+
plugin: plugin.id,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return models;
|
|
701
|
+
}
|
|
702
|
+
// Execute a TTS request through the appropriate plugin
|
|
703
|
+
async executeTTSRequest(model, input, options = {}) {
|
|
704
|
+
// Validate model parameter
|
|
705
|
+
if (!model || typeof model !== 'string') {
|
|
706
|
+
throw new Error('Invalid model parameter: must be a non-empty string');
|
|
707
|
+
}
|
|
708
|
+
// Sanitize model parameter
|
|
709
|
+
const modelPattern = /^[a-zA-Z0-9\-_:.]+$/;
|
|
710
|
+
if (!modelPattern.test(model)) {
|
|
711
|
+
throw new Error(`Invalid model parameter: ${model} contains invalid characters`);
|
|
712
|
+
}
|
|
713
|
+
// Prevent path traversal
|
|
714
|
+
if (model.includes('..') || model.includes('//') || model.includes('\\')) {
|
|
715
|
+
throw new Error(`Invalid model parameter: ${model} contains invalid patterns`);
|
|
716
|
+
}
|
|
717
|
+
const plugin = this.getPluginForTTS(model);
|
|
718
|
+
if (!plugin) {
|
|
719
|
+
throw new Error(`No TTS plugin found for model: ${model}`);
|
|
720
|
+
}
|
|
721
|
+
// Get API key from environment
|
|
722
|
+
const apiKey = process.env[plugin.auth.key_env];
|
|
723
|
+
if (!apiKey) {
|
|
724
|
+
throw new Error(`API key not found in environment variable: ${plugin.auth.key_env}`);
|
|
725
|
+
}
|
|
726
|
+
// Determine endpoint
|
|
727
|
+
let endpoint;
|
|
728
|
+
let ttsConfig;
|
|
729
|
+
if (plugin.capabilities?.tts) {
|
|
730
|
+
endpoint = plugin.capabilities.tts.endpoint;
|
|
731
|
+
ttsConfig = plugin.capabilities.tts.config;
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
endpoint = plugin.endpoint;
|
|
735
|
+
}
|
|
736
|
+
// Prepare headers
|
|
737
|
+
const headers = {
|
|
738
|
+
'Content-Type': 'application/json',
|
|
739
|
+
};
|
|
740
|
+
const authValue = plugin.auth.prefix
|
|
741
|
+
? `${plugin.auth.prefix}${apiKey}`
|
|
742
|
+
: apiKey;
|
|
743
|
+
headers[plugin.auth.header] = authValue;
|
|
744
|
+
// Apply defaults from config
|
|
745
|
+
const voice = options.voice || ttsConfig?.default_voice || 'alloy';
|
|
746
|
+
const responseFormat = options.response_format || ttsConfig?.default_format || 'mp3';
|
|
747
|
+
const speed = options.speed || 1.0;
|
|
748
|
+
// Check if input needs chunking (for long texts)
|
|
749
|
+
const maxChars = ttsConfig?.max_characters || 4096;
|
|
750
|
+
if (input.length > maxChars) {
|
|
751
|
+
// Split text into chunks and process each, then concatenate audio
|
|
752
|
+
const chunks = this.splitTextForTTS(input, maxChars);
|
|
753
|
+
console.log(`[TTS] Input too long (${input.length} chars), splitting into ${chunks.length} chunks`);
|
|
754
|
+
const audioBuffers = [];
|
|
755
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
756
|
+
console.log(`[TTS] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
|
|
757
|
+
// Recursive call with chunk (will not re-chunk since it's under limit)
|
|
758
|
+
const chunkAudio = await this.executeTTSRequest(model, chunks[i], options);
|
|
759
|
+
audioBuffers.push(chunkAudio);
|
|
760
|
+
}
|
|
761
|
+
// Concatenate all audio buffers
|
|
762
|
+
return Buffer.concat(audioBuffers);
|
|
763
|
+
}
|
|
764
|
+
// Prepare request payload and endpoint based on plugin type
|
|
765
|
+
let payload;
|
|
766
|
+
let processedEndpoint;
|
|
767
|
+
if (plugin.id === 'elevenlabs') {
|
|
768
|
+
// ElevenLabs API format
|
|
769
|
+
// ElevenLabs uses voice IDs - map voice names to IDs
|
|
770
|
+
const elevenLabsVoiceIds = {
|
|
771
|
+
rachel: '21m00Tcm4TlvDq8ikWAM',
|
|
772
|
+
domi: 'AZnzlk1XvdvUeBnXmlld',
|
|
773
|
+
bella: 'EXAVITQu4vr4xnSDxMaL',
|
|
774
|
+
antoni: 'ErXwobaYiN019PkySvjV',
|
|
775
|
+
elli: 'MF3mGyEYCl7XYWbV9V6O',
|
|
776
|
+
josh: 'TxGEqnHWrfWFTfGW9XjX',
|
|
777
|
+
arnold: 'VR6AewLTigWG4xSOukaG',
|
|
778
|
+
adam: 'pNInz6obpgDQGcFmaJgB',
|
|
779
|
+
sam: 'yoZ06aMxZJJ28mfd3POQ',
|
|
780
|
+
nicole: 'piTKgcLEGmPE4e6mEKli',
|
|
781
|
+
glinda: 'z9fAnlkpzviPz146aGWa',
|
|
782
|
+
clyde: '2EiwWnXFnvU5JabPnv8n',
|
|
783
|
+
james: 'ZQe5CZNOzWyzPSCn5a3c',
|
|
784
|
+
charlotte: 'XB0fDUnXU5powFXDhCwa',
|
|
785
|
+
lily: 'pFZP5JQG7iQjIQuC4Bku',
|
|
786
|
+
serena: 'pMsXgVXv3BLzUgSXRplE',
|
|
787
|
+
};
|
|
788
|
+
const voiceId = elevenLabsVoiceIds[voice.toLowerCase()] ||
|
|
789
|
+
elevenLabsVoiceIds['rachel'] ||
|
|
790
|
+
'21m00Tcm4TlvDq8ikWAM';
|
|
791
|
+
processedEndpoint = `${endpoint}/${voiceId}`;
|
|
792
|
+
// Add output_format query parameter
|
|
793
|
+
const formatMap = {
|
|
794
|
+
mp3: 'mp3_44100_128',
|
|
795
|
+
pcm: 'pcm_16000',
|
|
796
|
+
ulaw: 'ulaw_8000',
|
|
797
|
+
};
|
|
798
|
+
const outputFormat = formatMap[responseFormat] || 'mp3_44100_128';
|
|
799
|
+
processedEndpoint += `?output_format=${outputFormat}`;
|
|
800
|
+
payload = {
|
|
801
|
+
text: input,
|
|
802
|
+
model_id: model,
|
|
803
|
+
voice_settings: {
|
|
804
|
+
stability: 0.5,
|
|
805
|
+
similarity_boost: 0.75,
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
// Default OpenAI TTS format
|
|
811
|
+
payload = {
|
|
812
|
+
model,
|
|
813
|
+
input,
|
|
814
|
+
voice,
|
|
815
|
+
response_format: responseFormat,
|
|
816
|
+
speed,
|
|
817
|
+
};
|
|
818
|
+
// Process endpoint template
|
|
819
|
+
const sanitizedModel = encodeURIComponent(model);
|
|
820
|
+
processedEndpoint = endpoint.replace('{model}', sanitizedModel);
|
|
821
|
+
}
|
|
822
|
+
// Validate the final endpoint URL
|
|
823
|
+
try {
|
|
824
|
+
const url = new URL(processedEndpoint);
|
|
825
|
+
const isLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
826
|
+
if (url.protocol !== 'https:' && !isLocalhost) {
|
|
827
|
+
throw new Error(`Insecure endpoint protocol: ${url.protocol}. Only HTTPS is allowed for remote endpoints.`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
catch (_error) {
|
|
831
|
+
throw new Error(`Invalid endpoint URL constructed: ${processedEndpoint}`);
|
|
832
|
+
}
|
|
833
|
+
try {
|
|
834
|
+
const response = await axios.post(processedEndpoint, payload, {
|
|
835
|
+
headers,
|
|
836
|
+
timeout: 120000, // 2 minute timeout for TTS
|
|
837
|
+
responseType: 'arraybuffer', // TTS returns binary audio data
|
|
838
|
+
});
|
|
839
|
+
return Buffer.from(response.data);
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
console.error(`TTS plugin request failed for ${plugin.id}:`, error);
|
|
843
|
+
if (error && typeof error === 'object' && 'response' in error) {
|
|
844
|
+
const axiosError = error;
|
|
845
|
+
// Try to parse error message from response
|
|
846
|
+
let errorMessage = axiosError.response.statusText;
|
|
847
|
+
if (axiosError.response.data) {
|
|
848
|
+
try {
|
|
849
|
+
const errorText = Buffer.from(axiosError.response.data).toString('utf8');
|
|
850
|
+
const errorJson = JSON.parse(errorText);
|
|
851
|
+
errorMessage = errorJson.error?.message || errorMessage;
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
// Ignore parse errors
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
throw new Error(`TTS API error: ${axiosError.response.status} - ${errorMessage}`);
|
|
858
|
+
}
|
|
859
|
+
else if (error && typeof error === 'object' && 'request' in error) {
|
|
860
|
+
throw new Error(`TTS connection error: Unable to reach ${processedEndpoint}`);
|
|
861
|
+
}
|
|
862
|
+
else {
|
|
863
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
864
|
+
throw new Error(`TTS error: ${errorMessage}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
// Split text into chunks for TTS, trying to break at sentence boundaries
|
|
869
|
+
splitTextForTTS(text, maxChars) {
|
|
870
|
+
const chunks = [];
|
|
871
|
+
let remaining = text;
|
|
872
|
+
while (remaining.length > 0) {
|
|
873
|
+
if (remaining.length <= maxChars) {
|
|
874
|
+
chunks.push(remaining);
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
// Try to find a good break point (sentence end) within the limit
|
|
878
|
+
let breakPoint = maxChars;
|
|
879
|
+
const searchStart = Math.max(0, maxChars - 500); // Look in last 500 chars for sentence end
|
|
880
|
+
// Look for sentence endings (. ! ?) followed by space or end
|
|
881
|
+
const sentenceEnders = ['. ', '! ', '? ', '.\n', '!\n', '?\n'];
|
|
882
|
+
let bestBreak = -1;
|
|
883
|
+
for (const ender of sentenceEnders) {
|
|
884
|
+
const lastIndex = remaining.lastIndexOf(ender, maxChars);
|
|
885
|
+
if (lastIndex > searchStart && lastIndex > bestBreak) {
|
|
886
|
+
bestBreak = lastIndex + ender.length;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (bestBreak > searchStart) {
|
|
890
|
+
breakPoint = bestBreak;
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
// Fall back to breaking at whitespace
|
|
894
|
+
const lastSpace = remaining.lastIndexOf(' ', maxChars);
|
|
895
|
+
if (lastSpace > searchStart) {
|
|
896
|
+
breakPoint = lastSpace + 1;
|
|
897
|
+
}
|
|
898
|
+
// If no good break found, just break at maxChars (may split mid-word)
|
|
899
|
+
}
|
|
900
|
+
chunks.push(remaining.slice(0, breakPoint).trim());
|
|
901
|
+
remaining = remaining.slice(breakPoint).trim();
|
|
902
|
+
}
|
|
903
|
+
return chunks.filter(chunk => chunk.length > 0);
|
|
904
|
+
}
|
|
905
|
+
// Get TTS configuration for a specific plugin
|
|
906
|
+
getTTSConfig(pluginId) {
|
|
907
|
+
const plugin = this.getPlugin(pluginId);
|
|
908
|
+
if (!plugin)
|
|
909
|
+
return null;
|
|
910
|
+
if (plugin.capabilities?.tts?.config) {
|
|
911
|
+
return plugin.capabilities.tts.config;
|
|
912
|
+
}
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
// Get all plugins that support a specific capability type
|
|
916
|
+
getPluginsByCapability(capabilityType) {
|
|
917
|
+
const allPlugins = this.getAllPlugins();
|
|
918
|
+
const result = [];
|
|
919
|
+
for (const plugin of allPlugins) {
|
|
920
|
+
// Check if primary type matches
|
|
921
|
+
if (plugin.type === capabilityType) {
|
|
922
|
+
const apiKey = process.env[plugin.auth.key_env];
|
|
923
|
+
if (apiKey) {
|
|
924
|
+
result.push(plugin);
|
|
925
|
+
}
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
// Check capabilities object based on capability type
|
|
929
|
+
if (plugin.capabilities) {
|
|
930
|
+
let hasCapability = false;
|
|
931
|
+
switch (capabilityType) {
|
|
932
|
+
case 'tts':
|
|
933
|
+
hasCapability = !!plugin.capabilities.tts;
|
|
934
|
+
break;
|
|
935
|
+
case 'stt':
|
|
936
|
+
hasCapability = !!plugin.capabilities.stt;
|
|
937
|
+
break;
|
|
938
|
+
case 'embedding':
|
|
939
|
+
hasCapability = !!plugin.capabilities.embedding;
|
|
940
|
+
break;
|
|
941
|
+
case 'image':
|
|
942
|
+
hasCapability = !!plugin.capabilities.image;
|
|
943
|
+
break;
|
|
944
|
+
case 'completion':
|
|
945
|
+
case 'chat':
|
|
946
|
+
hasCapability = !!plugin.capabilities.completion;
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
if (hasCapability) {
|
|
950
|
+
const apiKey = process.env[plugin.auth.key_env];
|
|
951
|
+
if (apiKey) {
|
|
952
|
+
result.push(plugin);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return result;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
export default new PluginService();
|
|
961
|
+
//# sourceMappingURL=pluginService.js.map
|