@intranefr/superbackend 1.4.3
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/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- package/views/partials/footer.ejs +3 -0
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>LLM / AI - Admin</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<style>
|
|
9
|
+
.toast { animation: slideIn 0.3s ease-out; }
|
|
10
|
+
@keyframes slideIn {
|
|
11
|
+
from { transform: translateX(100%); opacity: 0; }
|
|
12
|
+
to { transform: translateX(0); opacity: 1; }
|
|
13
|
+
}
|
|
14
|
+
.fade-out { animation: fadeOut 0.3s ease-out forwards; }
|
|
15
|
+
@keyframes fadeOut {
|
|
16
|
+
from { opacity: 1; }
|
|
17
|
+
to { opacity: 0; }
|
|
18
|
+
}
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body class="bg-gray-100">
|
|
22
|
+
<div class="min-h-screen">
|
|
23
|
+
<div class="bg-white shadow">
|
|
24
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
25
|
+
<div class="flex justify-between items-center">
|
|
26
|
+
<div>
|
|
27
|
+
<h1 class="text-2xl font-bold text-gray-900">LLM / AI</h1>
|
|
28
|
+
<p class="text-sm text-gray-600 mt-1">Configure OpenAI-compatible providers, prompts, and inspect LLM audit events.</p>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="flex items-center gap-4">
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
|
37
|
+
<!-- Providers + Prompts -->
|
|
38
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
39
|
+
<!-- Providers -->
|
|
40
|
+
<div class="bg-white rounded-lg shadow">
|
|
41
|
+
<div class="px-4 py-3 border-b flex items-center justify-between">
|
|
42
|
+
<div>
|
|
43
|
+
<h2 class="text-lg font-semibold text-gray-900">Providers</h2>
|
|
44
|
+
<p class="text-sm text-gray-600">OpenAI-compatible endpoints used by prompts.</p>
|
|
45
|
+
</div>
|
|
46
|
+
<button id="btnAddProvider" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">New Provider</button>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="p-4 space-y-4">
|
|
49
|
+
<div class="overflow-x-auto">
|
|
50
|
+
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
|
51
|
+
<thead class="bg-gray-50">
|
|
52
|
+
<tr>
|
|
53
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-xs">Key</th>
|
|
54
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-xs">Label</th>
|
|
55
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-xs">Preset</th>
|
|
56
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-xs">Base URL</th>
|
|
57
|
+
<th class="px-3 py-2 text-center font-medium text-gray-500 uppercase text-xs">Enabled</th>
|
|
58
|
+
<th class="px-3 py-2"></th>
|
|
59
|
+
</tr>
|
|
60
|
+
</thead>
|
|
61
|
+
<tbody id="providersTable" class="bg-white divide-y divide-gray-200"></tbody>
|
|
62
|
+
</table>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div id="providerEditor" class="border rounded p-4 bg-gray-50 hidden">
|
|
66
|
+
<h3 class="text-sm font-semibold mb-3" id="providerEditorTitle">New Provider</h3>
|
|
67
|
+
<div class="grid grid-cols-1 gap-3">
|
|
68
|
+
<div>
|
|
69
|
+
<label class="block text-xs font-medium mb-1">Key *</label>
|
|
70
|
+
<input id="providerKey" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="e.g. openai-main" />
|
|
71
|
+
</div>
|
|
72
|
+
<div>
|
|
73
|
+
<label class="block text-xs font-medium mb-1">Label</label>
|
|
74
|
+
<input id="providerLabel" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="Human readable name" />
|
|
75
|
+
</div>
|
|
76
|
+
<div>
|
|
77
|
+
<label class="block text-xs font-medium mb-1">Preset</label>
|
|
78
|
+
<select id="providerPreset" class="w-full border rounded px-2 py-1 text-sm">
|
|
79
|
+
<option value="custom">Custom</option>
|
|
80
|
+
<option value="openai">OpenAI</option>
|
|
81
|
+
<option value="openrouter">OpenRouter</option>
|
|
82
|
+
<option value="groq">Groq</option>
|
|
83
|
+
<option value="mistral">Mistral</option>
|
|
84
|
+
</select>
|
|
85
|
+
</div>
|
|
86
|
+
<div>
|
|
87
|
+
<label class="block text-xs font-medium mb-1">Base URL *</label>
|
|
88
|
+
<input id="providerBaseUrl" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="https://api.openai.com/v1" />
|
|
89
|
+
</div>
|
|
90
|
+
<div>
|
|
91
|
+
<label class="block text-xs font-medium mb-1">API Key *</label>
|
|
92
|
+
<input id="providerApiKey" type="password" class="w-full border rounded px-2 py-1 text-sm" placeholder="sk-..." />
|
|
93
|
+
<p class="text-[11px] text-gray-500 mt-1">Stored server-side in GlobalSetting JSON; not exposed publicly.</p>
|
|
94
|
+
</div>
|
|
95
|
+
<div>
|
|
96
|
+
<label class="block text-xs font-medium mb-1">Default model</label>
|
|
97
|
+
<input id="providerDefaultModel" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="e.g. gpt-4.1-mini" />
|
|
98
|
+
</div>
|
|
99
|
+
<div>
|
|
100
|
+
<label class="block text-xs font-medium mb-1">Model pricing (JSON, optional)</label>
|
|
101
|
+
<textarea id="providerModelPricing" rows="4" class="w-full border rounded px-2 py-1 text-sm font-mono" placeholder="{
|
|
102
|
+
\"gpt-4.1-mini\": { \"costPerMillionIn\": 0.15, \"costPerMillionOut\": 0.6 }
|
|
103
|
+
}"></textarea>
|
|
104
|
+
<p class="text-[11px] text-gray-500 mt-1">Used to compute fallback cost when the provider does not return usage.cost.</p>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="grid grid-cols-2 gap-3 items-center">
|
|
107
|
+
<label class="flex items-center text-xs font-medium">
|
|
108
|
+
<input id="providerEnabled" type="checkbox" class="mr-2" checked />
|
|
109
|
+
Enabled
|
|
110
|
+
</label>
|
|
111
|
+
<div>
|
|
112
|
+
<label class="block text-xs font-medium mb-1">Timeout (ms)</label>
|
|
113
|
+
<input id="providerTimeout" type="number" min="1000" value="60000" class="w-full border rounded px-2 py-1 text-sm" />
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="mt-4 flex justify-end gap-2">
|
|
118
|
+
<button id="btnCancelProvider" class="px-3 py-1.5 bg-gray-200 text-gray-800 rounded text-sm">Cancel</button>
|
|
119
|
+
<button id="btnSaveProvider" class="px-3 py-1.5 bg-blue-600 text-white rounded text-sm">Save Provider</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<!-- Prompts -->
|
|
126
|
+
<div class="bg-white rounded-lg shadow">
|
|
127
|
+
<div class="px-4 py-3 border-b flex items-center justify-between">
|
|
128
|
+
<div>
|
|
129
|
+
<h2 class="text-lg font-semibold text-gray-900">Prompts</h2>
|
|
130
|
+
<p class="text-sm text-gray-600">Named templates that call configured providers/models.</p>
|
|
131
|
+
</div>
|
|
132
|
+
<button id="btnAddPrompt" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">New Prompt</button>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="p-4 space-y-4">
|
|
135
|
+
<div class="overflow-x-auto">
|
|
136
|
+
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
|
137
|
+
<thead class="bg-gray-50">
|
|
138
|
+
<tr>
|
|
139
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-xs">Key</th>
|
|
140
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-xs">Label</th>
|
|
141
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-xs">Provider</th>
|
|
142
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-xs">Model</th>
|
|
143
|
+
<th class="px-3 py-2 text-center font-medium text-gray-500 uppercase text-xs">Enabled</th>
|
|
144
|
+
<th class="px-3 py-2"></th>
|
|
145
|
+
</tr>
|
|
146
|
+
</thead>
|
|
147
|
+
<tbody id="promptsTable" class="bg-white divide-y divide-gray-200"></tbody>
|
|
148
|
+
</table>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div id="promptEditor" class="border rounded p-4 bg-gray-50 hidden">
|
|
152
|
+
<h3 class="text-sm font-semibold mb-3" id="promptEditorTitle">New Prompt</h3>
|
|
153
|
+
<div class="grid grid-cols-1 gap-3">
|
|
154
|
+
<div>
|
|
155
|
+
<label class="block text-xs font-medium mb-1">Key *</label>
|
|
156
|
+
<input id="promptKey" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="e.g. tell_joke" />
|
|
157
|
+
</div>
|
|
158
|
+
<div>
|
|
159
|
+
<label class="block text-xs font-medium mb-1">Label</label>
|
|
160
|
+
<input id="promptLabel" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="Human readable name" />
|
|
161
|
+
</div>
|
|
162
|
+
<div>
|
|
163
|
+
<label class="block text-xs font-medium mb-1">Description</label>
|
|
164
|
+
<input id="promptDescription" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="Short summary of what this prompt does" />
|
|
165
|
+
</div>
|
|
166
|
+
<div class="grid grid-cols-2 gap-3">
|
|
167
|
+
<div>
|
|
168
|
+
<label class="block text-xs font-medium mb-1">Provider key *</label>
|
|
169
|
+
<input id="promptProviderKey" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="Must match a provider key" />
|
|
170
|
+
</div>
|
|
171
|
+
<div>
|
|
172
|
+
<label class="block text-xs font-medium mb-1">Model</label>
|
|
173
|
+
<input id="promptModel" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="Override provider default model (optional)" />
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<div>
|
|
177
|
+
<label class="block text-xs font-medium mb-1">Template *</label>
|
|
178
|
+
<textarea id="promptTemplate" rows="4" class="w-full border rounded px-2 py-1 text-sm font-mono" placeholder="Tell me a joke about {theme}"></textarea>
|
|
179
|
+
<p class="text-[11px] text-gray-500 mt-1">Use <code>{variable}</code> placeholders. They are replaced from the variables object.</p>
|
|
180
|
+
</div>
|
|
181
|
+
<div>
|
|
182
|
+
<label class="block text-xs font-medium mb-1">Default options (JSON)</label>
|
|
183
|
+
<textarea id="promptDefaultOptions" rows="4" class="w-full border rounded px-2 py-1 text-sm font-mono">{
|
|
184
|
+
"temperature": 0.7,
|
|
185
|
+
"max_tokens": 256
|
|
186
|
+
}</textarea>
|
|
187
|
+
</div>
|
|
188
|
+
<div>
|
|
189
|
+
<label class="block text-xs font-medium mb-1">Input schema (JSON, optional)</label>
|
|
190
|
+
<textarea id="promptInputSchema" rows="4" class="w-full border rounded px-2 py-1 text-sm font-mono" placeholder="{
|
|
191
|
+
"theme": { "type": "string", "label": "Theme", "required": true }
|
|
192
|
+
}"></textarea>
|
|
193
|
+
</div>
|
|
194
|
+
<div>
|
|
195
|
+
<label class="flex items-center text-xs font-medium">
|
|
196
|
+
<input id="promptEnabled" type="checkbox" class="mr-2" checked />
|
|
197
|
+
Enabled
|
|
198
|
+
</label>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="mt-4 flex justify-end gap-2">
|
|
202
|
+
<button id="btnCancelPrompt" class="px-3 py-1.5 bg-gray-200 text-gray-800 rounded text-sm">Cancel</button>
|
|
203
|
+
<button id="btnSavePrompt" class="px-3 py-1.5 bg-blue-600 text-white rounded text-sm">Save Prompt</button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<!-- Test + Audit -->
|
|
211
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
212
|
+
<!-- Test prompt -->
|
|
213
|
+
<div class="bg-white rounded-lg shadow">
|
|
214
|
+
<div class="px-4 py-3 border-b flex items-center justify-between">
|
|
215
|
+
<div>
|
|
216
|
+
<h2 class="text-lg font-semibold text-gray-900">Test Prompt</h2>
|
|
217
|
+
<p class="text-sm text-gray-600">Run a configured prompt with sample variables and see the response.</p>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<div class="p-4 space-y-3">
|
|
221
|
+
<div>
|
|
222
|
+
<label class="block text-xs font-medium mb-1">Prompt key</label>
|
|
223
|
+
<input id="testPromptKey" type="text" class="w-full border rounded px-2 py-1 text-sm" placeholder="e.g. tell_joke" />
|
|
224
|
+
</div>
|
|
225
|
+
<div>
|
|
226
|
+
<label class="block text-xs font-medium mb-1">Variables (JSON)</label>
|
|
227
|
+
<textarea id="testVariables" rows="4" class="w-full border rounded px-2 py-1 text-sm font-mono">{
|
|
228
|
+
"theme": "universe"
|
|
229
|
+
}</textarea>
|
|
230
|
+
</div>
|
|
231
|
+
<div>
|
|
232
|
+
<label class="block text-xs font-medium mb-1">Runtime options (JSON, optional)</label>
|
|
233
|
+
<textarea id="testOptions" rows="3" class="w-full border rounded px-2 py-1 text-sm font-mono">{
|
|
234
|
+
"temperature": 0.8
|
|
235
|
+
}</textarea>
|
|
236
|
+
</div>
|
|
237
|
+
<div class="flex justify-end">
|
|
238
|
+
<button id="btnRunTest" class="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">Run Test</button>
|
|
239
|
+
</div>
|
|
240
|
+
<div>
|
|
241
|
+
<label class="block text-xs font-medium mb-1">Result</label>
|
|
242
|
+
<pre id="testResult" class="bg-gray-50 border rounded p-3 text-sm overflow-auto max-h-64 whitespace-pre-wrap"></pre>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<!-- LLM Audit -->
|
|
248
|
+
<div class="bg-white rounded-lg shadow">
|
|
249
|
+
<div class="px-4 py-3 border-b flex items-center justify-between">
|
|
250
|
+
<div>
|
|
251
|
+
<h2 class="text-lg font-semibold text-gray-900">LLM Audit</h2>
|
|
252
|
+
<p class="text-sm text-gray-600">Recent LLM completions (action = llm.completion).</p>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
<div class="p-4 space-y-3">
|
|
256
|
+
<div class="grid grid-cols-3 gap-3 text-xs">
|
|
257
|
+
<div>
|
|
258
|
+
<label class="block font-medium mb-1">Prompt key</label>
|
|
259
|
+
<input id="auditPromptKey" class="w-full border rounded px-2 py-1" placeholder="optional" />
|
|
260
|
+
</div>
|
|
261
|
+
<div>
|
|
262
|
+
<label class="block font-medium mb-1">Provider key</label>
|
|
263
|
+
<input id="auditProviderKey" class="w-full border rounded px-2 py-1" placeholder="optional" />
|
|
264
|
+
</div>
|
|
265
|
+
<div>
|
|
266
|
+
<label class="block font-medium mb-1">Status</label>
|
|
267
|
+
<select id="auditStatus" class="w-full border rounded px-2 py-1">
|
|
268
|
+
<option value="">Any</option>
|
|
269
|
+
<option value="success">Success</option>
|
|
270
|
+
<option value="failure">Failure</option>
|
|
271
|
+
</select>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="flex items-center justify-between text-xs mt-1">
|
|
275
|
+
<div>
|
|
276
|
+
<label class="mr-1">Page size</label>
|
|
277
|
+
<select id="auditPageSize" class="border rounded px-2 py-1">
|
|
278
|
+
<option value="20">20</option>
|
|
279
|
+
<option value="50">50</option>
|
|
280
|
+
<option value="100">100</option>
|
|
281
|
+
</select>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="flex gap-2 items-center">
|
|
284
|
+
<button id="btnAuditPrev" class="px-2 py-1 bg-gray-200 text-gray-800 rounded disabled:opacity-50">Prev</button>
|
|
285
|
+
<span id="auditPageLabel" class="text-gray-600">Page 1</span>
|
|
286
|
+
<button id="btnAuditNext" class="px-2 py-1 bg-gray-200 text-gray-800 rounded disabled:opacity-50">Next</button>
|
|
287
|
+
<button id="btnAuditReload" class="px-3 py-1 bg-blue-600 text-white rounded">Reload</button>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="overflow-x-auto border rounded">
|
|
291
|
+
<table class="min-w-full divide-y divide-gray-200 text-xs">
|
|
292
|
+
<thead class="bg-gray-50">
|
|
293
|
+
<tr>
|
|
294
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Time</th>
|
|
295
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Prompt</th>
|
|
296
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Provider</th>
|
|
297
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Model</th>
|
|
298
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Status</th>
|
|
299
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Tokens</th>
|
|
300
|
+
</tr>
|
|
301
|
+
</thead>
|
|
302
|
+
<tbody id="auditTable" class="bg-white divide-y divide-gray-200"></tbody>
|
|
303
|
+
</table>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<!-- Cost Tracking -->
|
|
310
|
+
<div class="bg-white rounded-lg shadow">
|
|
311
|
+
<div class="px-4 py-3 border-b flex items-center justify-between">
|
|
312
|
+
<div>
|
|
313
|
+
<h2 class="text-lg font-semibold text-gray-900">Cost Tracking</h2>
|
|
314
|
+
<p class="text-sm text-gray-600">Manual search over successful LLM completions (tokens + USD cost if available).</p>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
<div class="p-4 space-y-3">
|
|
318
|
+
<div class="grid grid-cols-4 gap-3 text-xs">
|
|
319
|
+
<div>
|
|
320
|
+
<label class="block font-medium mb-1">Prompt key</label>
|
|
321
|
+
<input id="costPromptKey" class="w-full border rounded px-2 py-1" placeholder="optional" />
|
|
322
|
+
</div>
|
|
323
|
+
<div>
|
|
324
|
+
<label class="block font-medium mb-1">Provider key</label>
|
|
325
|
+
<input id="costProviderKey" class="w-full border rounded px-2 py-1" placeholder="optional" />
|
|
326
|
+
</div>
|
|
327
|
+
<div>
|
|
328
|
+
<label class="block font-medium mb-1">Model</label>
|
|
329
|
+
<input id="costModel" class="w-full border rounded px-2 py-1" placeholder="optional" />
|
|
330
|
+
</div>
|
|
331
|
+
<div>
|
|
332
|
+
<label class="block font-medium mb-1">Page size</label>
|
|
333
|
+
<select id="costPageSize" class="w-full border rounded px-2 py-1">
|
|
334
|
+
<option value="20">20</option>
|
|
335
|
+
<option value="50">50</option>
|
|
336
|
+
<option value="100">100</option>
|
|
337
|
+
</select>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
<div class="flex items-center justify-between text-xs mt-1">
|
|
341
|
+
<div class="flex gap-2 items-center">
|
|
342
|
+
<button id="btnCostPrev" class="px-2 py-1 bg-gray-200 text-gray-800 rounded disabled:opacity-50">Prev</button>
|
|
343
|
+
<span id="costPageLabel" class="text-gray-600">Page 1</span>
|
|
344
|
+
<button id="btnCostNext" class="px-2 py-1 bg-gray-200 text-gray-800 rounded disabled:opacity-50">Next</button>
|
|
345
|
+
</div>
|
|
346
|
+
<div class="flex gap-2 items-center">
|
|
347
|
+
<button id="btnCostSearch" class="px-3 py-1 bg-blue-600 text-white rounded">Search</button>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
<div class="overflow-x-auto border rounded">
|
|
351
|
+
<table class="min-w-full divide-y divide-gray-200 text-xs">
|
|
352
|
+
<thead class="bg-gray-50">
|
|
353
|
+
<tr>
|
|
354
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Time</th>
|
|
355
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Prompt</th>
|
|
356
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Provider</th>
|
|
357
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Model</th>
|
|
358
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Tokens (in/out/total)</th>
|
|
359
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Cost (USD)</th>
|
|
360
|
+
<th class="px-3 py-2 text-left font-medium text-gray-500 uppercase text-[10px]">Cost source</th>
|
|
361
|
+
</tr>
|
|
362
|
+
</thead>
|
|
363
|
+
<tbody id="costTable" class="bg-white divide-y divide-gray-200"></tbody>
|
|
364
|
+
</table>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
<div class="mt-4 bg-blue-50 border-l-4 border-blue-500 p-4 text-sm text-blue-900">
|
|
370
|
+
<p class="font-semibold mb-1">Storage model</p>
|
|
371
|
+
<p>Providers and prompts are stored in <code>GlobalSetting</code> rows as JSON under keys <code>llm.providers</code> and <code>llm.prompts</code>. Use the internal service:</p>
|
|
372
|
+
<pre class="mt-2 bg-blue-100 rounded p-2 text-xs overflow-auto">const saasbackend = require('saasbackend');
|
|
373
|
+
|
|
374
|
+
const result = await saasbackend.services.llm.call('tell_joke', {
|
|
375
|
+
theme: 'universe',
|
|
376
|
+
}, {
|
|
377
|
+
temperature: 0.8,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
console.log(result.content);</pre>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<div id="toast-container" class="fixed top-4 right-4 space-y-2 z-50"></div>
|
|
386
|
+
|
|
387
|
+
<script>
|
|
388
|
+
const API_BASE = window.location.origin + "<%= baseUrl %>" || window.location.origin;
|
|
389
|
+
let providers = {};
|
|
390
|
+
let prompts = {};
|
|
391
|
+
let auditPage = 1;
|
|
392
|
+
let costPage = 1;
|
|
393
|
+
|
|
394
|
+
function showToast(message, type = 'success') {
|
|
395
|
+
const container = document.getElementById('toast-container');
|
|
396
|
+
const toast = document.createElement('div');
|
|
397
|
+
toast.className = `toast px-4 py-3 rounded-lg shadow-lg text-white text-sm ${
|
|
398
|
+
type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
|
399
|
+
}`;
|
|
400
|
+
toast.textContent = message;
|
|
401
|
+
container.appendChild(toast);
|
|
402
|
+
setTimeout(() => {
|
|
403
|
+
toast.classList.add('fade-out');
|
|
404
|
+
setTimeout(() => toast.remove(), 300);
|
|
405
|
+
}, 3000);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderProviders() {
|
|
409
|
+
const tbody = document.getElementById('providersTable');
|
|
410
|
+
tbody.innerHTML = '';
|
|
411
|
+
const entries = Object.entries(providers || {});
|
|
412
|
+
if (!entries.length) {
|
|
413
|
+
const tr = document.createElement('tr');
|
|
414
|
+
tr.innerHTML = `<td colspan="6" class="px-3 py-4 text-center text-xs text-gray-500">No providers configured yet.</td>`;
|
|
415
|
+
tbody.appendChild(tr);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
for (const [key, p] of entries) {
|
|
419
|
+
const tr = document.createElement('tr');
|
|
420
|
+
tr.className = 'hover:bg-gray-50';
|
|
421
|
+
tr.innerHTML = `
|
|
422
|
+
<td class="px-3 py-2 text-xs text-gray-900">${escapeHtml(key)}</td>
|
|
423
|
+
<td class="px-3 py-2 text-xs text-gray-900">${escapeHtml(p.label || '')}</td>
|
|
424
|
+
<td class="px-3 py-2 text-xs text-gray-700">${escapeHtml(p.preset || 'custom')}</td>
|
|
425
|
+
<td class="px-3 py-2 text-xs text-gray-700 truncate max-w-[160px]" title="${escapeHtml(p.baseUrl || '')}">${escapeHtml(p.baseUrl || '')}</td>
|
|
426
|
+
<td class="px-3 py-2 text-xs text-center">${p.enabled === false ? '<span class="px-2 py-0.5 rounded bg-gray-100 text-gray-600">off</span>' : '<span class="px-2 py-0.5 rounded bg-green-100 text-green-700">on</span>'}</td>
|
|
427
|
+
<td class="px-3 py-2 text-xs text-right">
|
|
428
|
+
<button class="text-blue-600 hover:text-blue-800 mr-2" onclick="editProvider('${escapeJs(key)}')">Edit</button>
|
|
429
|
+
<button class="text-red-600 hover:text-red-800" onclick="deleteProvider('${escapeJs(key)}')">Delete</button>
|
|
430
|
+
</td>`;
|
|
431
|
+
tbody.appendChild(tr);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function renderPrompts() {
|
|
436
|
+
const tbody = document.getElementById('promptsTable');
|
|
437
|
+
tbody.innerHTML = '';
|
|
438
|
+
const entries = Object.entries(prompts || {});
|
|
439
|
+
if (!entries.length) {
|
|
440
|
+
const tr = document.createElement('tr');
|
|
441
|
+
tr.innerHTML = `<td colspan="6" class="px-3 py-4 text-center text-xs text-gray-500">No prompts configured yet.</td>`;
|
|
442
|
+
tbody.appendChild(tr);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
for (const [key, p] of entries) {
|
|
446
|
+
const tr = document.createElement('tr');
|
|
447
|
+
tr.className = 'hover:bg-gray-50';
|
|
448
|
+
tr.innerHTML = `
|
|
449
|
+
<td class="px-3 py-2 text-xs text-gray-900">${escapeHtml(key)}</td>
|
|
450
|
+
<td class="px-3 py-2 text-xs text-gray-900">${escapeHtml(p.label || '')}</td>
|
|
451
|
+
<td class="px-3 py-2 text-xs text-gray-700">${escapeHtml(p.providerKey || '')}</td>
|
|
452
|
+
<td class="px-3 py-2 text-xs text-gray-700">${escapeHtml(p.model || '')}</td>
|
|
453
|
+
<td class="px-3 py-2 text-xs text-center">${p.enabled === false ? '<span class="px-2 py-0.5 rounded bg-gray-100 text-gray-600">off</span>' : '<span class="px-2 py-0.5 rounded bg-green-100 text-green-700">on</span>'}</td>
|
|
454
|
+
<td class="px-3 py-2 text-xs text-right">
|
|
455
|
+
<button class="text-blue-600 hover:text-blue-800 mr-2" onclick="editPrompt('${escapeJs(key)}')">Edit</button>
|
|
456
|
+
<button class="text-red-600 hover:text-red-800" onclick="deletePrompt('${escapeJs(key)}')">Delete</button>
|
|
457
|
+
</td>`;
|
|
458
|
+
tbody.appendChild(tr);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function escapeHtml(str) {
|
|
463
|
+
return String(str ?? '')
|
|
464
|
+
.replace(/&/g, '&')
|
|
465
|
+
.replace(/</g, '<')
|
|
466
|
+
.replace(/>/g, '>')
|
|
467
|
+
.replace(/"/g, '"')
|
|
468
|
+
.replace(/'/g, ''');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function escapeJs(str) {
|
|
472
|
+
return String(str ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function loadConfig() {
|
|
476
|
+
try {
|
|
477
|
+
const res = await fetch(`${API_BASE}/api/admin/llm/config`);
|
|
478
|
+
const data = await res.json();
|
|
479
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to load config');
|
|
480
|
+
providers = data.providers || {};
|
|
481
|
+
prompts = data.prompts || {};
|
|
482
|
+
renderProviders();
|
|
483
|
+
renderPrompts();
|
|
484
|
+
} catch (e) {
|
|
485
|
+
showToast(e.message, 'error');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function saveConfig() {
|
|
490
|
+
try {
|
|
491
|
+
const res = await fetch(`${API_BASE}/api/admin/llm/config`, {
|
|
492
|
+
method: 'POST',
|
|
493
|
+
headers: { 'Content-Type': 'application/json' },
|
|
494
|
+
body: JSON.stringify({ providers, prompts }),
|
|
495
|
+
});
|
|
496
|
+
const data = await res.json();
|
|
497
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to save config');
|
|
498
|
+
showToast('Config saved');
|
|
499
|
+
} catch (e) {
|
|
500
|
+
showToast(e.message, 'error');
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function applyProviderPresetDefaults() {
|
|
505
|
+
const preset = document.getElementById('providerPreset').value;
|
|
506
|
+
const baseUrlInput = document.getElementById('providerBaseUrl');
|
|
507
|
+
const current = baseUrlInput.value.trim();
|
|
508
|
+
|
|
509
|
+
if (current) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (preset === 'openai') {
|
|
514
|
+
baseUrlInput.value = 'https://api.openai.com/v1';
|
|
515
|
+
} else if (preset === 'openrouter') {
|
|
516
|
+
baseUrlInput.value = 'https://openrouter.ai/api/v1';
|
|
517
|
+
} else if (preset === 'groq') {
|
|
518
|
+
baseUrlInput.value = 'https://api.groq.com/openai/v1';
|
|
519
|
+
} else if (preset === 'mistral') {
|
|
520
|
+
baseUrlInput.value = 'https://api.mistral.ai/v1';
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function openProviderEditor(key) {
|
|
525
|
+
const editor = document.getElementById('providerEditor');
|
|
526
|
+
const title = document.getElementById('providerEditorTitle');
|
|
527
|
+
const p = key && providers[key] ? providers[key] : null;
|
|
528
|
+
title.textContent = p ? `Edit Provider: ${key}` : 'New Provider';
|
|
529
|
+
document.getElementById('providerKey').value = key || '';
|
|
530
|
+
document.getElementById('providerKey').disabled = Boolean(p);
|
|
531
|
+
document.getElementById('providerLabel').value = p?.label || '';
|
|
532
|
+
document.getElementById('providerPreset').value = p?.preset || 'custom';
|
|
533
|
+
document.getElementById('providerBaseUrl').value = p?.baseUrl || '';
|
|
534
|
+
document.getElementById('providerApiKey').value = p?.apiKey || '';
|
|
535
|
+
document.getElementById('providerDefaultModel').value = p?.defaultModel || '';
|
|
536
|
+
document.getElementById('providerModelPricing').value = p?.modelPricing
|
|
537
|
+
? JSON.stringify(p.modelPricing, null, 2)
|
|
538
|
+
: '';
|
|
539
|
+
document.getElementById('providerEnabled').checked = p ? p.enabled !== false : true;
|
|
540
|
+
document.getElementById('providerTimeout').value = String(p?.timeoutMs ?? 60000);
|
|
541
|
+
editor.classList.remove('hidden');
|
|
542
|
+
|
|
543
|
+
applyProviderPresetDefaults();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function closeProviderEditor() {
|
|
547
|
+
document.getElementById('providerEditor').classList.add('hidden');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function createOrUpdateProviderFromForm() {
|
|
551
|
+
const key = document.getElementById('providerKey').value.trim();
|
|
552
|
+
if (!key) {
|
|
553
|
+
showToast('Provider key is required', 'error');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const next = providers ? { ...providers } : {};
|
|
557
|
+
const existing = next[key] || {};
|
|
558
|
+
const preset = document.getElementById('providerPreset').value || 'custom';
|
|
559
|
+
let baseUrl = document.getElementById('providerBaseUrl').value.trim();
|
|
560
|
+
if (!baseUrl) {
|
|
561
|
+
if (preset === 'openai') baseUrl = 'https://api.openai.com/v1';
|
|
562
|
+
if (preset === 'openrouter') baseUrl = 'https://openrouter.ai/api/v1';
|
|
563
|
+
}
|
|
564
|
+
let modelPricing = {};
|
|
565
|
+
const rawModelPricing = document.getElementById('providerModelPricing').value;
|
|
566
|
+
if (rawModelPricing && rawModelPricing.trim()) {
|
|
567
|
+
try {
|
|
568
|
+
modelPricing = JSON.parse(rawModelPricing);
|
|
569
|
+
} catch (e) {
|
|
570
|
+
showToast('Invalid model pricing JSON: ' + e.message, 'error');
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
next[key] = {
|
|
575
|
+
...existing,
|
|
576
|
+
label: document.getElementById('providerLabel').value.trim() || key,
|
|
577
|
+
preset,
|
|
578
|
+
baseUrl,
|
|
579
|
+
apiKey: document.getElementById('providerApiKey').value.trim(),
|
|
580
|
+
defaultModel: document.getElementById('providerDefaultModel').value.trim(),
|
|
581
|
+
modelPricing,
|
|
582
|
+
enabled: document.getElementById('providerEnabled').checked,
|
|
583
|
+
timeoutMs: Number(document.getElementById('providerTimeout').value || 60000),
|
|
584
|
+
};
|
|
585
|
+
providers = next;
|
|
586
|
+
renderProviders();
|
|
587
|
+
closeProviderEditor();
|
|
588
|
+
saveConfig();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function deleteProvider(key) {
|
|
592
|
+
if (!confirm(`Delete provider "${key}"?`)) return;
|
|
593
|
+
const next = { ...(providers || {}) };
|
|
594
|
+
delete next[key];
|
|
595
|
+
providers = next;
|
|
596
|
+
renderProviders();
|
|
597
|
+
saveConfig();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function openPromptEditor(key) {
|
|
601
|
+
const editor = document.getElementById('promptEditor');
|
|
602
|
+
const title = document.getElementById('promptEditorTitle');
|
|
603
|
+
const p = key && prompts[key] ? prompts[key] : null;
|
|
604
|
+
title.textContent = p ? `Edit Prompt: ${key}` : 'New Prompt';
|
|
605
|
+
document.getElementById('promptKey').value = key || '';
|
|
606
|
+
document.getElementById('promptKey').disabled = Boolean(p);
|
|
607
|
+
document.getElementById('promptLabel').value = p?.label || '';
|
|
608
|
+
document.getElementById('promptDescription').value = p?.description || '';
|
|
609
|
+
document.getElementById('promptProviderKey').value = p?.providerKey || '';
|
|
610
|
+
document.getElementById('promptModel').value = p?.model || '';
|
|
611
|
+
document.getElementById('promptTemplate').value = p?.template || '';
|
|
612
|
+
document.getElementById('promptEnabled').checked = p ? p.enabled !== false : true;
|
|
613
|
+
document.getElementById('promptDefaultOptions').value = JSON.stringify(p?.defaultOptions ?? { temperature: 0.7, max_tokens: 256 }, null, 2);
|
|
614
|
+
document.getElementById('promptInputSchema').value = p?.inputSchema ? JSON.stringify(p.inputSchema, null, 2) : '';
|
|
615
|
+
editor.classList.remove('hidden');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function closePromptEditor() {
|
|
619
|
+
document.getElementById('promptEditor').classList.add('hidden');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function createOrUpdatePromptFromForm() {
|
|
623
|
+
const key = document.getElementById('promptKey').value.trim();
|
|
624
|
+
if (!key) {
|
|
625
|
+
showToast('Prompt key is required', 'error');
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const providerKey = document.getElementById('promptProviderKey').value.trim();
|
|
629
|
+
if (!providerKey) {
|
|
630
|
+
showToast('Prompt provider key is required', 'error');
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const template = document.getElementById('promptTemplate').value;
|
|
634
|
+
if (!template.trim()) {
|
|
635
|
+
showToast('Prompt template is required', 'error');
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
let defaultOptions = {};
|
|
639
|
+
const rawOptions = document.getElementById('promptDefaultOptions').value;
|
|
640
|
+
if (rawOptions && rawOptions.trim()) {
|
|
641
|
+
try {
|
|
642
|
+
defaultOptions = JSON.parse(rawOptions);
|
|
643
|
+
} catch (e) {
|
|
644
|
+
showToast('Invalid default options JSON: ' + e.message, 'error');
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
let inputSchema = null;
|
|
649
|
+
const rawSchema = document.getElementById('promptInputSchema').value;
|
|
650
|
+
if (rawSchema && rawSchema.trim()) {
|
|
651
|
+
try {
|
|
652
|
+
inputSchema = JSON.parse(rawSchema);
|
|
653
|
+
} catch (e) {
|
|
654
|
+
showToast('Invalid input schema JSON: ' + e.message, 'error');
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const next = prompts ? { ...prompts } : {};
|
|
659
|
+
next[key] = {
|
|
660
|
+
label: document.getElementById('promptLabel').value.trim() || key,
|
|
661
|
+
description: document.getElementById('promptDescription').value.trim(),
|
|
662
|
+
providerKey,
|
|
663
|
+
model: document.getElementById('promptModel').value.trim(),
|
|
664
|
+
template,
|
|
665
|
+
defaultOptions,
|
|
666
|
+
inputSchema,
|
|
667
|
+
enabled: document.getElementById('promptEnabled').checked,
|
|
668
|
+
};
|
|
669
|
+
prompts = next;
|
|
670
|
+
renderPrompts();
|
|
671
|
+
closePromptEditor();
|
|
672
|
+
saveConfig();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function deletePrompt(key) {
|
|
676
|
+
if (!confirm(`Delete prompt "${key}"?`)) return;
|
|
677
|
+
const next = { ...(prompts || {}) };
|
|
678
|
+
delete next[key];
|
|
679
|
+
prompts = next;
|
|
680
|
+
renderPrompts();
|
|
681
|
+
saveConfig();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function runTest() {
|
|
685
|
+
const key = document.getElementById('testPromptKey').value.trim();
|
|
686
|
+
if (!key) {
|
|
687
|
+
showToast('Prompt key is required', 'error');
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
let variables = {};
|
|
691
|
+
let options = {};
|
|
692
|
+
try {
|
|
693
|
+
const rawVars = document.getElementById('testVariables').value;
|
|
694
|
+
variables = rawVars && rawVars.trim() ? JSON.parse(rawVars) : {};
|
|
695
|
+
} catch (e) {
|
|
696
|
+
showToast('Invalid variables JSON: ' + e.message, 'error');
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
const rawOpts = document.getElementById('testOptions').value;
|
|
701
|
+
options = rawOpts && rawOpts.trim() ? JSON.parse(rawOpts) : {};
|
|
702
|
+
} catch (e) {
|
|
703
|
+
showToast('Invalid options JSON: ' + e.message, 'error');
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const resultEl = document.getElementById('testResult');
|
|
707
|
+
resultEl.textContent = 'Running...';
|
|
708
|
+
try {
|
|
709
|
+
const res = await fetch(`${API_BASE}/api/admin/llm/prompts/${encodeURIComponent(key)}/test`, {
|
|
710
|
+
method: 'POST',
|
|
711
|
+
headers: { 'Content-Type': 'application/json' },
|
|
712
|
+
body: JSON.stringify({ variables, options }),
|
|
713
|
+
});
|
|
714
|
+
const data = await res.json();
|
|
715
|
+
if (!res.ok) throw new Error(data?.error || 'Test failed');
|
|
716
|
+
resultEl.textContent = JSON.stringify(data.result, null, 2);
|
|
717
|
+
showToast('Test completed');
|
|
718
|
+
} catch (e) {
|
|
719
|
+
resultEl.textContent = 'Error: ' + e.message;
|
|
720
|
+
showToast(e.message, 'error');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function loadAudit() {
|
|
725
|
+
const promptKey = document.getElementById('auditPromptKey').value.trim();
|
|
726
|
+
const providerKey = document.getElementById('auditProviderKey').value.trim();
|
|
727
|
+
const status = document.getElementById('auditStatus').value;
|
|
728
|
+
const pageSize = Number(document.getElementById('auditPageSize').value || 20);
|
|
729
|
+
const params = new URLSearchParams();
|
|
730
|
+
params.set('page', String(auditPage));
|
|
731
|
+
params.set('pageSize', String(pageSize));
|
|
732
|
+
if (promptKey) params.set('promptKey', promptKey);
|
|
733
|
+
if (providerKey) params.set('providerKey', providerKey);
|
|
734
|
+
if (status) params.set('status', status);
|
|
735
|
+
try {
|
|
736
|
+
const res = await fetch(`${API_BASE}/api/admin/llm/audit?${params.toString()}`);
|
|
737
|
+
const data = await res.json();
|
|
738
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to load audit');
|
|
739
|
+
renderAudit(data);
|
|
740
|
+
} catch (e) {
|
|
741
|
+
showToast(e.message, 'error');
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function renderAudit(data) {
|
|
746
|
+
const tbody = document.getElementById('auditTable');
|
|
747
|
+
tbody.innerHTML = '';
|
|
748
|
+
const items = Array.isArray(data?.items) ? data.items : [];
|
|
749
|
+
if (!items.length) {
|
|
750
|
+
const tr = document.createElement('tr');
|
|
751
|
+
tr.innerHTML = `<td colspan="6" class="px-3 py-4 text-center text-xs text-gray-500">No audit entries.</td>`;
|
|
752
|
+
tbody.appendChild(tr);
|
|
753
|
+
} else {
|
|
754
|
+
for (const ev of items) {
|
|
755
|
+
const meta = ev.meta || {};
|
|
756
|
+
const createdAt = ev.createdAt ? new Date(ev.createdAt) : null;
|
|
757
|
+
const tokensPrompt = meta.usage?.prompt_tokens ?? '';
|
|
758
|
+
const tokensCompletion = meta.usage?.completion_tokens ?? '';
|
|
759
|
+
const tr = document.createElement('tr');
|
|
760
|
+
tr.innerHTML = `
|
|
761
|
+
<td class="px-3 py-2 text-[11px] text-gray-700 whitespace-nowrap">${createdAt ? createdAt.toLocaleString() : ''}</td>
|
|
762
|
+
<td class="px-3 py-2 text-[11px] text-gray-900">${escapeHtml(String(meta.promptKey || ''))}</td>
|
|
763
|
+
<td class="px-3 py-2 text-[11px] text-gray-700">${escapeHtml(String(meta.providerKey || ''))}</td>
|
|
764
|
+
<td class="px-3 py-2 text-[11px] text-gray-700">${escapeHtml(String(meta.model || ''))}</td>
|
|
765
|
+
<td class="px-3 py-2 text-[11px]">${ev.outcome === 'failure'
|
|
766
|
+
? '<span class="px-2 py-0.5 rounded bg-red-100 text-red-700">failure</span>'
|
|
767
|
+
: '<span class="px-2 py-0.5 rounded bg-green-100 text-green-700">success</span>'}</td>
|
|
768
|
+
<td class="px-3 py-2 text-[11px] text-gray-700">${tokensPrompt || tokensCompletion ? `${tokensPrompt}/${tokensCompletion}` : ''}</td>`;
|
|
769
|
+
tbody.appendChild(tr);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const page = Number(data?.page || 1);
|
|
773
|
+
const pageSize = Number(data?.pageSize || 20);
|
|
774
|
+
const total = Number(data?.total || 0);
|
|
775
|
+
const maxPage = total ? Math.ceil(total / pageSize) : 1;
|
|
776
|
+
document.getElementById('auditPageLabel').textContent = `Page ${page} of ${maxPage}`;
|
|
777
|
+
document.getElementById('btnAuditPrev').disabled = page <= 1;
|
|
778
|
+
document.getElementById('btnAuditNext').disabled = page >= maxPage;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function loadCosts() {
|
|
782
|
+
const promptKey = document.getElementById('costPromptKey').value.trim();
|
|
783
|
+
const providerKey = document.getElementById('costProviderKey').value.trim();
|
|
784
|
+
const model = document.getElementById('costModel').value.trim();
|
|
785
|
+
const pageSize = Number(document.getElementById('costPageSize').value || 20);
|
|
786
|
+
const params = new URLSearchParams();
|
|
787
|
+
params.set('page', String(costPage));
|
|
788
|
+
params.set('pageSize', String(pageSize));
|
|
789
|
+
if (promptKey) params.set('promptKey', promptKey);
|
|
790
|
+
if (providerKey) params.set('providerKey', providerKey);
|
|
791
|
+
if (model) params.set('model', model);
|
|
792
|
+
try {
|
|
793
|
+
const res = await fetch(`${API_BASE}/api/admin/llm/costs?${params.toString()}`);
|
|
794
|
+
const data = await res.json();
|
|
795
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to load costs');
|
|
796
|
+
renderCosts(data);
|
|
797
|
+
} catch (e) {
|
|
798
|
+
showToast(e.message, 'error');
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function formatUsd(value) {
|
|
803
|
+
if (value === null || value === undefined || value === '') return '';
|
|
804
|
+
const num = Number(value);
|
|
805
|
+
if (!Number.isFinite(num)) return '';
|
|
806
|
+
if (num === 0) return '0';
|
|
807
|
+
if (Math.abs(num) < 0.000001) return num.toExponential(2);
|
|
808
|
+
return num.toFixed(6);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function renderCosts(data) {
|
|
812
|
+
const tbody = document.getElementById('costTable');
|
|
813
|
+
tbody.innerHTML = '';
|
|
814
|
+
const items = Array.isArray(data?.items) ? data.items : [];
|
|
815
|
+
if (!items.length) {
|
|
816
|
+
const tr = document.createElement('tr');
|
|
817
|
+
tr.innerHTML = `<td colspan="7" class="px-3 py-4 text-center text-xs text-gray-500">No cost entries.</td>`;
|
|
818
|
+
tbody.appendChild(tr);
|
|
819
|
+
} else {
|
|
820
|
+
for (const row of items) {
|
|
821
|
+
const createdAt = row.createdAt ? new Date(row.createdAt) : null;
|
|
822
|
+
const tokensIn = row.prompt_tokens ?? '';
|
|
823
|
+
const tokensOut = row.completion_tokens ?? '';
|
|
824
|
+
const tokensTotal = row.total_tokens ?? '';
|
|
825
|
+
const tr = document.createElement('tr');
|
|
826
|
+
tr.innerHTML = `
|
|
827
|
+
<td class="px-3 py-2 text-[11px] text-gray-700 whitespace-nowrap">${createdAt ? createdAt.toLocaleString() : ''}</td>
|
|
828
|
+
<td class="px-3 py-2 text-[11px] text-gray-900">${escapeHtml(String(row.promptKey || ''))}</td>
|
|
829
|
+
<td class="px-3 py-2 text-[11px] text-gray-700">${escapeHtml(String(row.providerKey || ''))}</td>
|
|
830
|
+
<td class="px-3 py-2 text-[11px] text-gray-700">${escapeHtml(String(row.model || ''))}</td>
|
|
831
|
+
<td class="px-3 py-2 text-[11px] text-gray-700">${tokensIn || tokensOut || tokensTotal ? `${tokensIn}/${tokensOut}/${tokensTotal}` : ''}</td>
|
|
832
|
+
<td class="px-3 py-2 text-[11px] text-gray-700">${formatUsd(row.cost)}</td>
|
|
833
|
+
<td class="px-3 py-2 text-[11px] text-gray-500">${escapeHtml(String(row.cost_source || ''))}</td>`;
|
|
834
|
+
tbody.appendChild(tr);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const page = Number(data?.page || 1);
|
|
838
|
+
const pageSize = Number(data?.pageSize || 20);
|
|
839
|
+
const total = Number(data?.total || 0);
|
|
840
|
+
const maxPage = total ? Math.ceil(total / pageSize) : 1;
|
|
841
|
+
document.getElementById('costPageLabel').textContent = `Page ${page} of ${maxPage}`;
|
|
842
|
+
document.getElementById('btnCostPrev').disabled = page <= 1;
|
|
843
|
+
document.getElementById('btnCostNext').disabled = page >= maxPage;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
document.getElementById('btnAddProvider').addEventListener('click', () => openProviderEditor(null));
|
|
847
|
+
document.getElementById('btnCancelProvider').addEventListener('click', closeProviderEditor);
|
|
848
|
+
document.getElementById('btnSaveProvider').addEventListener('click', createOrUpdateProviderFromForm);
|
|
849
|
+
document.getElementById('providerPreset').addEventListener('change', applyProviderPresetDefaults);
|
|
850
|
+
|
|
851
|
+
document.getElementById('btnAddPrompt').addEventListener('click', () => openPromptEditor(null));
|
|
852
|
+
document.getElementById('btnCancelPrompt').addEventListener('click', closePromptEditor);
|
|
853
|
+
document.getElementById('btnSavePrompt').addEventListener('click', createOrUpdatePromptFromForm);
|
|
854
|
+
|
|
855
|
+
document.getElementById('btnRunTest').addEventListener('click', runTest);
|
|
856
|
+
|
|
857
|
+
document.getElementById('btnAuditReload').addEventListener('click', () => { auditPage = 1; loadAudit(); });
|
|
858
|
+
document.getElementById('btnAuditPrev').addEventListener('click', () => { if (auditPage > 1) { auditPage -= 1; loadAudit(); } });
|
|
859
|
+
document.getElementById('btnAuditNext').addEventListener('click', () => { auditPage += 1; loadAudit(); });
|
|
860
|
+
|
|
861
|
+
document.getElementById('btnCostSearch').addEventListener('click', () => { costPage = 1; loadCosts(); });
|
|
862
|
+
document.getElementById('btnCostPrev').addEventListener('click', () => { if (costPage > 1) { costPage -= 1; loadCosts(); } });
|
|
863
|
+
document.getElementById('btnCostNext').addEventListener('click', () => { costPage += 1; loadCosts(); });
|
|
864
|
+
|
|
865
|
+
window.editProvider = (key) => openProviderEditor(key);
|
|
866
|
+
window.deleteProvider = deleteProvider;
|
|
867
|
+
window.editPrompt = (key) => openPromptEditor(key);
|
|
868
|
+
window.deletePrompt = deletePrompt;
|
|
869
|
+
|
|
870
|
+
(async function init() {
|
|
871
|
+
await loadConfig();
|
|
872
|
+
await loadAudit();
|
|
873
|
+
})();
|
|
874
|
+
</script>
|
|
875
|
+
<script>
|
|
876
|
+
window.addEventListener("keydown", (e) => {
|
|
877
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
878
|
+
e.preventDefault();
|
|
879
|
+
window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
</script>
|
|
883
|
+
</body>
|
|
884
|
+
</html>
|