universal-chatbot-saas 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +22 -0
- package/README.md +93 -0
- package/package.json +28 -0
- package/public/chatbot.js +987 -0
- package/public/portal/admin-bots.html +142 -0
- package/public/portal/admin-bots.js +265 -0
- package/public/portal/admin-users.html +149 -0
- package/public/portal/admin-users.js +286 -0
- package/public/portal/login.html +65 -0
- package/public/portal/portal.js +266 -0
- package/react-wrapper/LICENSE +21 -0
- package/react-wrapper/README.md +48 -0
- package/react-wrapper/package.json +37 -0
- package/react-wrapper/src/index.tsx +137 -0
- package/react-wrapper/tsconfig.json +18 -0
- package/server/index.js +2331 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
let users = [];
|
|
2
|
+
let bots = [];
|
|
3
|
+
let selectedUser = null;
|
|
4
|
+
let isNewUser = false;
|
|
5
|
+
let searchTerm = '';
|
|
6
|
+
let activeFilter = 'all';
|
|
7
|
+
let currentPage = 1;
|
|
8
|
+
let pageSize = 10;
|
|
9
|
+
|
|
10
|
+
const el = {
|
|
11
|
+
status: document.getElementById('status'),
|
|
12
|
+
usersTableBody: document.getElementById('usersTableBody'),
|
|
13
|
+
modalBackdrop: document.getElementById('modalBackdrop'),
|
|
14
|
+
closeModalBtn: document.getElementById('closeModalBtn'),
|
|
15
|
+
formTitle: document.getElementById('formTitle'),
|
|
16
|
+
username: document.getElementById('username'),
|
|
17
|
+
password: document.getElementById('password'),
|
|
18
|
+
passwordHint: document.getElementById('passwordHint'),
|
|
19
|
+
isActive: document.getElementById('isActive'),
|
|
20
|
+
botChecklist: document.getElementById('botChecklist'),
|
|
21
|
+
newUserBtn: document.getElementById('newUserBtn'),
|
|
22
|
+
refreshBtn: document.getElementById('refreshBtn'),
|
|
23
|
+
searchInput: document.getElementById('searchInput'),
|
|
24
|
+
activeFilter: document.getElementById('activeFilter'),
|
|
25
|
+
pageSize: document.getElementById('pageSize'),
|
|
26
|
+
prevPageBtn: document.getElementById('prevPageBtn'),
|
|
27
|
+
nextPageBtn: document.getElementById('nextPageBtn'),
|
|
28
|
+
pageIndicator: document.getElementById('pageIndicator'),
|
|
29
|
+
pagerInfo: document.getElementById('pagerInfo'),
|
|
30
|
+
saveBtn: document.getElementById('saveBtn'),
|
|
31
|
+
cancelBtn: document.getElementById('cancelBtn'),
|
|
32
|
+
deleteBtn: document.getElementById('deleteBtn')
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function showStatus(message, type = 'success') {
|
|
36
|
+
el.status.textContent = message;
|
|
37
|
+
el.status.className = `status ${type}`;
|
|
38
|
+
if (type === 'success') {
|
|
39
|
+
setTimeout(() => { el.status.className = 'status'; }, 4000);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function escapeHtml(text) {
|
|
44
|
+
if (!text) return '';
|
|
45
|
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
46
|
+
return String(text).replace(/[&<>"']/g, (m) => map[m]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function selectedBotIdsFromForm() {
|
|
50
|
+
return Array.from(el.botChecklist.querySelectorAll('input[type="checkbox"]:checked')).map((node) => node.value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderBotChecklist(selectedIds) {
|
|
54
|
+
if (!bots.length) {
|
|
55
|
+
el.botChecklist.innerHTML = '<div class="muted">No bots found. Create bots first.</div>';
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const selected = new Set(selectedIds || []);
|
|
60
|
+
el.botChecklist.innerHTML = bots.map((bot) => {
|
|
61
|
+
const checked = selected.has(bot.id) ? 'checked' : '';
|
|
62
|
+
return `<label class="bot-item"><input type="checkbox" value="${escapeHtml(bot.id)}" ${checked} /> <span>${escapeHtml(bot.name || bot.id)}</span> <span class="muted">(${escapeHtml(bot.id)})</span></label>`;
|
|
63
|
+
}).join('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderUsers() {
|
|
67
|
+
const filtered = users.filter((user) => {
|
|
68
|
+
if (activeFilter === 'active' && !user.is_active) return false;
|
|
69
|
+
if (activeFilter === 'inactive' && user.is_active) return false;
|
|
70
|
+
if (!searchTerm) return true;
|
|
71
|
+
const haystack = [String(user.username || '').toLowerCase(), ...(Array.isArray(user.bot_ids) ? user.bot_ids.map((id) => String(id || '').toLowerCase()) : [])].join(' ');
|
|
72
|
+
return haystack.includes(searchTerm);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const total = filtered.length;
|
|
76
|
+
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
|
|
77
|
+
if (currentPage > totalPages) currentPage = totalPages;
|
|
78
|
+
const start = (currentPage - 1) * pageSize;
|
|
79
|
+
const pageRows = filtered.slice(start, start + pageSize);
|
|
80
|
+
|
|
81
|
+
el.pageIndicator.textContent = `Page ${currentPage} of ${totalPages}`;
|
|
82
|
+
el.pagerInfo.textContent = total ? `Showing ${start + 1}-${Math.min(start + pageSize, total)} of ${total} users` : 'Showing 0 users';
|
|
83
|
+
el.prevPageBtn.disabled = currentPage <= 1;
|
|
84
|
+
el.nextPageBtn.disabled = currentPage >= totalPages;
|
|
85
|
+
|
|
86
|
+
if (!pageRows.length) {
|
|
87
|
+
el.usersTableBody.innerHTML = '<tr><td colspan="4"><div class="empty">No users available</div></td></tr>';
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
el.usersTableBody.innerHTML = pageRows.map((user) => {
|
|
92
|
+
const chips = (user.bot_ids || []).map((botId) => `<span class="chip">${escapeHtml(botId)}</span>`).join('');
|
|
93
|
+
return `
|
|
94
|
+
<tr data-id="${user.id}">
|
|
95
|
+
<td>${escapeHtml(user.username)}</td>
|
|
96
|
+
<td>${user.is_active ? 'Yes' : 'No'}</td>
|
|
97
|
+
<td><div class="chips">${chips || '<span class="muted">None</span>'}</div></td>
|
|
98
|
+
<td>
|
|
99
|
+
<div class="actions">
|
|
100
|
+
<button class="secondary action-btn" data-action="edit" data-id="${user.id}">Edit</button>
|
|
101
|
+
<button class="danger action-btn" data-action="delete" data-id="${user.id}">Delete</button>
|
|
102
|
+
</div>
|
|
103
|
+
</td>
|
|
104
|
+
</tr>
|
|
105
|
+
`;
|
|
106
|
+
}).join('');
|
|
107
|
+
|
|
108
|
+
Array.from(el.usersTableBody.querySelectorAll('button[data-action]')).forEach((button) => {
|
|
109
|
+
button.addEventListener('click', (event) => {
|
|
110
|
+
event.stopPropagation();
|
|
111
|
+
const id = Number(button.getAttribute('data-id'));
|
|
112
|
+
const action = button.getAttribute('data-action');
|
|
113
|
+
const user = users.find((item) => item.id === id);
|
|
114
|
+
if (!user) return;
|
|
115
|
+
if (action === 'edit') {
|
|
116
|
+
openEditUser(user);
|
|
117
|
+
} else if (action === 'delete') {
|
|
118
|
+
deleteUser(user).catch((error) => showStatus(error.message, 'error'));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function loadBots() {
|
|
125
|
+
const response = await fetch('/admin/bots');
|
|
126
|
+
if (!response.ok) throw new Error(`Failed to load bots (${response.status})`);
|
|
127
|
+
bots = await response.json();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function loadUsers() {
|
|
131
|
+
const response = await fetch('/admin/users');
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
134
|
+
throw new Error(errorBody.error || `Failed to load users (${response.status})`);
|
|
135
|
+
}
|
|
136
|
+
users = await response.json();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function refreshAll() {
|
|
140
|
+
try {
|
|
141
|
+
el.refreshBtn.disabled = true;
|
|
142
|
+
await Promise.all([loadBots(), loadUsers()]);
|
|
143
|
+
currentPage = 1;
|
|
144
|
+
renderUsers();
|
|
145
|
+
if (selectedUser) {
|
|
146
|
+
const latest = users.find((u) => u.id === selectedUser.id);
|
|
147
|
+
if (latest) openEditUser(latest);
|
|
148
|
+
else cancelForm();
|
|
149
|
+
}
|
|
150
|
+
showStatus('Users loaded', 'success');
|
|
151
|
+
} catch (error) {
|
|
152
|
+
showStatus(error.message, 'error');
|
|
153
|
+
} finally {
|
|
154
|
+
el.refreshBtn.disabled = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function openNewUser() {
|
|
159
|
+
isNewUser = true;
|
|
160
|
+
selectedUser = null;
|
|
161
|
+
el.formTitle.textContent = 'New User';
|
|
162
|
+
el.username.value = '';
|
|
163
|
+
el.password.value = '';
|
|
164
|
+
el.password.placeholder = 'Enter password';
|
|
165
|
+
el.passwordHint.textContent = '*';
|
|
166
|
+
el.isActive.checked = true;
|
|
167
|
+
el.deleteBtn.style.display = 'none';
|
|
168
|
+
renderBotChecklist([]);
|
|
169
|
+
openModal();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function openEditUser(user) {
|
|
173
|
+
isNewUser = false;
|
|
174
|
+
selectedUser = user;
|
|
175
|
+
el.formTitle.textContent = `Edit User: ${user.username}`;
|
|
176
|
+
el.username.value = user.username || '';
|
|
177
|
+
el.password.value = '';
|
|
178
|
+
el.password.placeholder = 'Leave blank to keep current password';
|
|
179
|
+
el.passwordHint.textContent = '(optional on edit)';
|
|
180
|
+
el.isActive.checked = Boolean(user.is_active);
|
|
181
|
+
el.deleteBtn.style.display = 'inline-block';
|
|
182
|
+
renderBotChecklist(user.bot_ids || []);
|
|
183
|
+
openModal();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function openModal() {
|
|
187
|
+
el.modalBackdrop.classList.add('visible');
|
|
188
|
+
el.modalBackdrop.setAttribute('aria-hidden', 'false');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function closeModal() {
|
|
192
|
+
el.modalBackdrop.classList.remove('visible');
|
|
193
|
+
el.modalBackdrop.setAttribute('aria-hidden', 'true');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function cancelForm() {
|
|
197
|
+
selectedUser = null;
|
|
198
|
+
isNewUser = false;
|
|
199
|
+
closeModal();
|
|
200
|
+
renderUsers();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function saveUser() {
|
|
204
|
+
const username = el.username.value.trim();
|
|
205
|
+
const password = el.password.value.trim();
|
|
206
|
+
const payload = {
|
|
207
|
+
username,
|
|
208
|
+
is_active: el.isActive.checked,
|
|
209
|
+
bot_ids: selectedBotIdsFromForm()
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (!username) {
|
|
213
|
+
showStatus('Username is required', 'error');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (isNewUser && !password) {
|
|
217
|
+
showStatus('Password is required for new user', 'error');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (password) payload.password = password;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
el.saveBtn.disabled = true;
|
|
224
|
+
const response = isNewUser
|
|
225
|
+
? await fetch('/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
|
226
|
+
: await fetch(`/admin/users/${selectedUser.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
|
227
|
+
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
230
|
+
throw new Error(errorBody.error || `Request failed (${response.status})`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
showStatus(isNewUser ? 'User created' : 'User updated', 'success');
|
|
234
|
+
await refreshAll();
|
|
235
|
+
cancelForm();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
showStatus(error.message, 'error');
|
|
238
|
+
} finally {
|
|
239
|
+
el.saveBtn.disabled = false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function deleteUser(userArg) {
|
|
244
|
+
const user = userArg || selectedUser;
|
|
245
|
+
if (!user) return;
|
|
246
|
+
const confirmed = confirm(`Delete user ${user.username}?`);
|
|
247
|
+
if (!confirmed) return;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
el.deleteBtn.disabled = true;
|
|
251
|
+
const response = await fetch(`/admin/users/${user.id}`, { method: 'DELETE' });
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
254
|
+
throw new Error(errorBody.error || `Delete failed (${response.status})`);
|
|
255
|
+
}
|
|
256
|
+
showStatus('User deleted', 'success');
|
|
257
|
+
await refreshAll();
|
|
258
|
+
cancelForm();
|
|
259
|
+
} catch (error) {
|
|
260
|
+
showStatus(error.message, 'error');
|
|
261
|
+
} finally {
|
|
262
|
+
el.deleteBtn.disabled = false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
el.newUserBtn.addEventListener('click', openNewUser);
|
|
267
|
+
el.refreshBtn.addEventListener('click', refreshAll);
|
|
268
|
+
el.saveBtn.addEventListener('click', saveUser);
|
|
269
|
+
el.cancelBtn.addEventListener('click', cancelForm);
|
|
270
|
+
el.deleteBtn.addEventListener('click', deleteUser);
|
|
271
|
+
el.closeModalBtn.addEventListener('click', cancelForm);
|
|
272
|
+
el.searchInput.addEventListener('input', (event) => { searchTerm = String(event.target.value || '').trim().toLowerCase(); currentPage = 1; renderUsers(); });
|
|
273
|
+
el.activeFilter.addEventListener('change', (event) => { activeFilter = String(event.target.value || 'all'); currentPage = 1; renderUsers(); });
|
|
274
|
+
el.pageSize.addEventListener('change', (event) => { pageSize = Number(event.target.value || 10); currentPage = 1; renderUsers(); });
|
|
275
|
+
el.prevPageBtn.addEventListener('click', () => { if (currentPage > 1) { currentPage -= 1; renderUsers(); } });
|
|
276
|
+
el.nextPageBtn.addEventListener('click', () => { currentPage += 1; renderUsers(); });
|
|
277
|
+
el.modalBackdrop.addEventListener('click', (event) => {
|
|
278
|
+
if (event.target === el.modalBackdrop) cancelForm();
|
|
279
|
+
});
|
|
280
|
+
document.addEventListener('keydown', (event) => {
|
|
281
|
+
if (event.key === 'Escape' && el.modalBackdrop.classList.contains('visible')) {
|
|
282
|
+
cancelForm();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
document.addEventListener('DOMContentLoaded', refreshAll);
|
|
@@ -0,0 +1,65 @@
|
|
|
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>Portal Login</title>
|
|
7
|
+
<style>
|
|
8
|
+
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:linear-gradient(135deg,#0f172a,#134e4a);min-height:100vh;display:grid;place-items:center;color:#0f172a}
|
|
9
|
+
.card{width:min(420px,calc(100vw - 32px));background:#fff;border-radius:20px;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.25)}
|
|
10
|
+
h1{margin:0 0 8px;color:#0ea5a4}.sub{margin:0 0 18px;color:#64748b;font-size:14px}
|
|
11
|
+
label{display:block;font-size:13px;font-weight:600;margin:14px 0 6px}
|
|
12
|
+
input{width:100%;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:10px;padding:12px 14px;font-size:14px}
|
|
13
|
+
button{width:100%;border:none;border-radius:10px;padding:12px 14px;background:#0ea5a4;color:#fff;font-size:14px;font-weight:700;cursor:pointer;margin-top:18px}
|
|
14
|
+
.msg{display:none;margin-top:14px;padding:10px 12px;border-radius:10px;font-size:14px}.msg.ok{display:block;background:#dcfce7;color:#166534}.msg.err{display:block;background:#fee2e2;color:#991b1b}
|
|
15
|
+
.small{color:#64748b;font-size:12px;margin-top:10px}
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div class="card">
|
|
20
|
+
<h1>Portal Login</h1>
|
|
21
|
+
<p class="sub">Sign in to access the bot knowledge portal.</p>
|
|
22
|
+
<form id="form">
|
|
23
|
+
<label for="username">Username</label>
|
|
24
|
+
<input id="username" autocomplete="username">
|
|
25
|
+
<label for="password">Password</label>
|
|
26
|
+
<input id="password" type="password" autocomplete="current-password">
|
|
27
|
+
<button type="submit">Login</button>
|
|
28
|
+
<div id="msg" class="msg"></div>
|
|
29
|
+
<div class="small" id="botInfo"></div>
|
|
30
|
+
</form>
|
|
31
|
+
</div>
|
|
32
|
+
<script>
|
|
33
|
+
const params = new URLSearchParams(location.search);
|
|
34
|
+
const botId = params.get('botId') || '';
|
|
35
|
+
document.getElementById('botInfo').textContent = botId ? `Bot: ${botId}` : 'Bot id is required.';
|
|
36
|
+
const msg = document.getElementById('msg');
|
|
37
|
+
document.getElementById('form').addEventListener('submit', async (event) => {
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch('/portal/api/auth/login', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
username: document.getElementById('username').value,
|
|
45
|
+
password: document.getElementById('password').value,
|
|
46
|
+
botId
|
|
47
|
+
})
|
|
48
|
+
});
|
|
49
|
+
const body = await response.json().catch(() => ({}));
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
msg.className = 'msg err';
|
|
52
|
+
msg.textContent = body.error || `Login failed (${response.status})`;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
msg.className = 'msg ok';
|
|
56
|
+
msg.textContent = 'Login successful. Redirecting...';
|
|
57
|
+
setTimeout(() => { location.href = `/portal/index.html?botId=${encodeURIComponent(botId)}`; }, 600);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
msg.className = 'msg err';
|
|
60
|
+
msg.textContent = error.message || String(error);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
</script>
|
|
64
|
+
</body>
|
|
65
|
+
</html>
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
let items = [];
|
|
2
|
+
let selectedId = null;
|
|
3
|
+
let editingId = null;
|
|
4
|
+
let currentPage = 1;
|
|
5
|
+
let pageSize = 10;
|
|
6
|
+
|
|
7
|
+
const el = {
|
|
8
|
+
status: document.getElementById('status'),
|
|
9
|
+
botId: document.getElementById('botId'),
|
|
10
|
+
search: document.getElementById('search'),
|
|
11
|
+
pageSize: document.getElementById('pageSize'),
|
|
12
|
+
loadBtn: document.getElementById('loadBtn'),
|
|
13
|
+
newBtn: document.getElementById('newBtn'),
|
|
14
|
+
rows: document.getElementById('rows'),
|
|
15
|
+
countInfo: document.getElementById('countInfo'),
|
|
16
|
+
pageInfo: document.getElementById('pageInfo'),
|
|
17
|
+
prevBtn: document.getElementById('prevBtn'),
|
|
18
|
+
nextBtn: document.getElementById('nextBtn'),
|
|
19
|
+
modalBackdrop: document.getElementById('modalBackdrop'),
|
|
20
|
+
closeModalBtn: document.getElementById('closeModalBtn'),
|
|
21
|
+
formTitle: document.getElementById('formTitle'),
|
|
22
|
+
question: document.getElementById('question'),
|
|
23
|
+
answer: document.getElementById('answer'),
|
|
24
|
+
tags: document.getElementById('tags'),
|
|
25
|
+
source: document.getElementById('source'),
|
|
26
|
+
saveBtn: document.getElementById('saveBtn'),
|
|
27
|
+
cancelBtn: document.getElementById('cancelBtn'),
|
|
28
|
+
deleteBtn: document.getElementById('deleteBtn')
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function showStatus(message, ok = true) {
|
|
32
|
+
el.status.className = `status ${ok ? 'ok' : 'err'}`;
|
|
33
|
+
el.status.textContent = message;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function escapeHtml(text) {
|
|
37
|
+
return String(text || '')
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>')
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/'/g, ''');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tagsFromInput(value) {
|
|
46
|
+
return String(value || '')
|
|
47
|
+
.split(',')
|
|
48
|
+
.map((tag) => tag.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getRequiredBotId() {
|
|
53
|
+
const botId = String(el.botId.value || '').trim();
|
|
54
|
+
if (!botId) {
|
|
55
|
+
throw new Error('bot id is required');
|
|
56
|
+
}
|
|
57
|
+
return botId;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getVisibleItems() {
|
|
61
|
+
const query = String(el.search.value || '').trim().toLowerCase();
|
|
62
|
+
return items.filter((item) => {
|
|
63
|
+
if (!query) return true;
|
|
64
|
+
const haystack = [item.keyword, item.title, item.answer, item.content, item.source, ...(item.tags || [])]
|
|
65
|
+
.join(' ')
|
|
66
|
+
.toLowerCase();
|
|
67
|
+
return haystack.includes(query);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function render() {
|
|
72
|
+
const filtered = getVisibleItems();
|
|
73
|
+
const total = filtered.length;
|
|
74
|
+
const totalPages = Math.max(Math.ceil(total / pageSize), 1);
|
|
75
|
+
if (currentPage > totalPages) currentPage = totalPages;
|
|
76
|
+
const start = (currentPage - 1) * pageSize;
|
|
77
|
+
const pageItems = filtered.slice(start, start + pageSize);
|
|
78
|
+
|
|
79
|
+
el.countInfo.textContent = total ? `Showing ${start + 1}-${Math.min(start + pageSize, total)} of ${total}` : 'No knowledge entries';
|
|
80
|
+
el.pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
|
|
81
|
+
el.prevBtn.disabled = currentPage <= 1;
|
|
82
|
+
el.nextBtn.disabled = currentPage >= totalPages;
|
|
83
|
+
|
|
84
|
+
if (!pageItems.length) {
|
|
85
|
+
el.rows.innerHTML = '<tr><td colspan="5"><div class="empty">No matching entries found.</div></td></tr>';
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
el.rows.innerHTML = pageItems.map((item) => {
|
|
90
|
+
const answerText = String(item.answer || item.content || '');
|
|
91
|
+
const tags = Array.isArray(item.tags) ? item.tags : [];
|
|
92
|
+
return `
|
|
93
|
+
<tr data-id="${item.id}">
|
|
94
|
+
<td>${escapeHtml(item.keyword || item.title || '')}</td>
|
|
95
|
+
<td>${escapeHtml(answerText.slice(0, 140))}${answerText.length > 140 ? '…' : ''}</td>
|
|
96
|
+
<td><div class="chips">${tags.map((tag) => `<span class="chip">${escapeHtml(tag)}</span>`).join('')}</div></td>
|
|
97
|
+
<td>${escapeHtml(item.source || '')}</td>
|
|
98
|
+
<td>
|
|
99
|
+
<div class="actions">
|
|
100
|
+
<button class="btn secondary action-btn" data-action="edit" data-id="${item.id}">Edit</button>
|
|
101
|
+
<button class="btn danger action-btn" data-action="delete" data-id="${item.id}">Delete</button>
|
|
102
|
+
</div>
|
|
103
|
+
</td>
|
|
104
|
+
</tr>
|
|
105
|
+
`;
|
|
106
|
+
}).join('');
|
|
107
|
+
|
|
108
|
+
Array.from(el.rows.querySelectorAll('button[data-action]')).forEach((button) => {
|
|
109
|
+
button.addEventListener('click', (event) => {
|
|
110
|
+
event.stopPropagation();
|
|
111
|
+
const id = Number(button.getAttribute('data-id'));
|
|
112
|
+
const action = button.getAttribute('data-action');
|
|
113
|
+
if (action === 'edit') {
|
|
114
|
+
openModalForEdit(id);
|
|
115
|
+
} else if (action === 'delete') {
|
|
116
|
+
deleteItem(id).catch((error) => showStatus(error.message, false));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function openModal() {
|
|
123
|
+
el.modalBackdrop.classList.add('visible');
|
|
124
|
+
el.modalBackdrop.setAttribute('aria-hidden', 'false');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function closeModal() {
|
|
128
|
+
el.modalBackdrop.classList.remove('visible');
|
|
129
|
+
el.modalBackdrop.setAttribute('aria-hidden', 'true');
|
|
130
|
+
el.deleteBtn.style.display = 'none';
|
|
131
|
+
editingId = null;
|
|
132
|
+
selectedId = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fillForm(item) {
|
|
136
|
+
el.question.value = item?.keyword || item?.title || '';
|
|
137
|
+
el.answer.value = item?.answer || item?.content || '';
|
|
138
|
+
el.tags.value = Array.isArray(item?.tags) ? item.tags.join(', ') : '';
|
|
139
|
+
el.source.value = item?.source || 'portal';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function openModalForNew() {
|
|
143
|
+
try {
|
|
144
|
+
getRequiredBotId();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
showStatus(error.message, false);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
editingId = null;
|
|
151
|
+
selectedId = null;
|
|
152
|
+
el.formTitle.textContent = 'New Entry';
|
|
153
|
+
fillForm({ source: 'portal' });
|
|
154
|
+
el.deleteBtn.style.display = 'none';
|
|
155
|
+
openModal();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function openModalForEdit(id) {
|
|
159
|
+
const item = items.find((entry) => Number(entry.id) === Number(id));
|
|
160
|
+
if (!item) return;
|
|
161
|
+
editingId = item.id;
|
|
162
|
+
selectedId = item.id;
|
|
163
|
+
el.formTitle.textContent = `Edit Entry #${item.id}`;
|
|
164
|
+
fillForm(item);
|
|
165
|
+
el.deleteBtn.style.display = 'inline-flex';
|
|
166
|
+
openModal();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function loadItems() {
|
|
170
|
+
const botId = getRequiredBotId();
|
|
171
|
+
|
|
172
|
+
const response = await fetch(`/portal/api/knowledge?botId=${encodeURIComponent(botId)}`);
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
const error = await response.json().catch(() => ({}));
|
|
175
|
+
throw new Error(error.error || `Failed to load knowledge (${response.status})`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const payload = await response.json();
|
|
179
|
+
items = Array.isArray(payload.items) ? payload.items : [];
|
|
180
|
+
currentPage = 1;
|
|
181
|
+
render();
|
|
182
|
+
showStatus(`Loaded ${items.length} entries`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function saveItem() {
|
|
186
|
+
const botId = getRequiredBotId();
|
|
187
|
+
const answer = String(el.answer.value || '').trim();
|
|
188
|
+
const question = String(el.question.value || '').trim();
|
|
189
|
+
if (!answer) throw new Error('answer is required');
|
|
190
|
+
|
|
191
|
+
const payload = {
|
|
192
|
+
botId,
|
|
193
|
+
question,
|
|
194
|
+
answer,
|
|
195
|
+
tags: tagsFromInput(el.tags.value),
|
|
196
|
+
source: String(el.source.value || 'portal').trim() || 'portal'
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const response = editingId
|
|
200
|
+
? await fetch(`/portal/api/knowledge/${editingId}`, {
|
|
201
|
+
method: 'PUT',
|
|
202
|
+
headers: { 'Content-Type': 'application/json' },
|
|
203
|
+
body: JSON.stringify(payload)
|
|
204
|
+
})
|
|
205
|
+
: await fetch('/portal/api/knowledge', {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'Content-Type': 'application/json' },
|
|
208
|
+
body: JSON.stringify(payload)
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
const error = await response.json().catch(() => ({}));
|
|
213
|
+
throw new Error(error.error || `Request failed (${response.status})`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
showStatus(editingId ? 'Entry updated' : 'Entry created');
|
|
217
|
+
closeModal();
|
|
218
|
+
await loadItems();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function deleteItem(id = selectedId) {
|
|
222
|
+
if (!id) return;
|
|
223
|
+
if (!confirm('Delete this entry?')) return;
|
|
224
|
+
|
|
225
|
+
const botId = getRequiredBotId();
|
|
226
|
+
|
|
227
|
+
const response = await fetch(`/portal/api/knowledge/${id}?botId=${encodeURIComponent(botId)}`, { method: 'DELETE' });
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
const error = await response.json().catch(() => ({}));
|
|
230
|
+
throw new Error(error.error || `Request failed (${response.status})`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
showStatus('Entry deleted');
|
|
234
|
+
closeModal();
|
|
235
|
+
await loadItems();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
el.loadBtn.addEventListener('click', () => loadItems().catch((error) => showStatus(error.message, false)));
|
|
239
|
+
el.newBtn.addEventListener('click', openModalForNew);
|
|
240
|
+
el.saveBtn.addEventListener('click', () => saveItem().catch((error) => showStatus(error.message, false)));
|
|
241
|
+
el.cancelBtn.addEventListener('click', closeModal);
|
|
242
|
+
el.closeModalBtn.addEventListener('click', closeModal);
|
|
243
|
+
el.deleteBtn.addEventListener('click', () => deleteItem().catch((error) => showStatus(error.message, false)));
|
|
244
|
+
el.search.addEventListener('input', () => { currentPage = 1; render(); });
|
|
245
|
+
el.pageSize.addEventListener('change', () => { pageSize = Number(el.pageSize.value || 10); currentPage = 1; render(); });
|
|
246
|
+
el.prevBtn.addEventListener('click', () => { if (currentPage > 1) { currentPage -= 1; render(); } });
|
|
247
|
+
el.nextBtn.addEventListener('click', () => { currentPage += 1; render(); });
|
|
248
|
+
el.modalBackdrop.addEventListener('click', (event) => {
|
|
249
|
+
if (event.target === el.modalBackdrop) closeModal();
|
|
250
|
+
});
|
|
251
|
+
document.addEventListener('keydown', (event) => {
|
|
252
|
+
if (event.key === 'Escape' && el.modalBackdrop.classList.contains('visible')) {
|
|
253
|
+
closeModal();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const params = new URLSearchParams(location.search);
|
|
258
|
+
const botIdFromQuery = params.get('botId');
|
|
259
|
+
if (botIdFromQuery) {
|
|
260
|
+
el.botId.value = botIdFromQuery;
|
|
261
|
+
loadItems().catch((error) => showStatus(error.message, false));
|
|
262
|
+
} else {
|
|
263
|
+
showStatus('Add botId to the URL query string to load records.');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
el.botId.readOnly = true;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vengadesh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# universal_ai_chatbot
|
|
2
|
+
|
|
3
|
+
Production-ready React wrapper for the chatbot embed script.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install universal_ai_chatbot
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import ChatbotEmbed from "universal_ai_chatbot";
|
|
15
|
+
|
|
16
|
+
export default function App() {
|
|
17
|
+
return (
|
|
18
|
+
<ChatbotEmbed
|
|
19
|
+
src="https://your-domain.com/embed/chatbot.js"
|
|
20
|
+
apiBase="https://your-domain.com"
|
|
21
|
+
botId="sb009"
|
|
22
|
+
botApiKey="your-bot-api-key"
|
|
23
|
+
aiApikey="your-provider-api-key"
|
|
24
|
+
provider="groq"
|
|
25
|
+
model="llama-3.3-70b-versatile"
|
|
26
|
+
botName="Support Assistant"
|
|
27
|
+
color="#0ea5a4"
|
|
28
|
+
welcome="Hi! How can I help you today?"
|
|
29
|
+
position="bottom-right"
|
|
30
|
+
visitorId="visitor-123"
|
|
31
|
+
sessionId="session-456"
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Build
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm run build
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Publish
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm login
|
|
47
|
+
npm publish --access public
|
|
48
|
+
```
|