nodebb-plugin-search-agent 0.0.1

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.
@@ -0,0 +1,281 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Search Agent – client-side entry point.
5
+ *
6
+ * Responsibilities:
7
+ * 1. Inject the floating action button (FAB) into the page.
8
+ * 2. Render the chat panel HTML.
9
+ * 3. Handle open / close toggle.
10
+ * 4. Send queries to the backend API.
11
+ * 5. Render results (or error / empty states).
12
+ *
13
+ * All DOM work is deferred until NodeBB reports it is ready so that the
14
+ * app's Bootstrap and icon libraries are guaranteed to be present.
15
+ */
16
+
17
+ // Use AMD require so the callback runs synchronously after the modules are
18
+ // resolved — this avoids the race condition where 'action:app.load' has
19
+ // already fired before an async await chain completes.
20
+ console.log('[search-agent] main.js: module loaded');
21
+
22
+ require(['hooks', 'api', 'translator'], function (hooks, api, translator) {
23
+ console.log('[search-agent] main.js: modules resolved, registering hooks');
24
+
25
+ // 'action:app.load' fires once after NodeBB initialises.
26
+ hooks.on('action:app.load', () => {
27
+ console.log('[search-agent] action:app.load fired — checking config before mounting');
28
+ mountIfAllowed({ api, translator });
29
+ });
30
+
31
+ // 'action:ajaxify.end' fires on every client-side navigation.
32
+ hooks.on('action:ajaxify.end', () => {
33
+ if (!document.getElementById('search-agent-fab')) {
34
+ console.log('[search-agent] action:ajaxify.end — FAB missing, re-checking config');
35
+ mountIfAllowed({ api, translator });
36
+ }
37
+ });
38
+ });
39
+
40
+ // ─── Visibility gate ──────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Fetch the plugin's public config, check whether the current user is allowed
44
+ * to see the widget, then mount (or skip) accordingly.
45
+ */
46
+ function mountIfAllowed({ api, translator }) {
47
+ api.get('/plugins/search-agent/config', {})
48
+ .then(function (config) {
49
+ const user = window.app && window.app.user;
50
+ const isAdmin = user && user.isAdmin;
51
+ const uid = (user && user.uid) || 0;
52
+
53
+ if (config.visibleTo === 'admins' && !isAdmin) {
54
+ console.log('[search-agent] mountIfAllowed: widget hidden (admins only, user is not admin)');
55
+ return;
56
+ }
57
+
58
+ if (!config.guestsAllowed && uid === 0) {
59
+ console.log('[search-agent] mountIfAllowed: widget hidden (guests not allowed)');
60
+ return;
61
+ }
62
+
63
+ mountSearchAgent({ api, translator });
64
+ })
65
+ .catch(function (err) {
66
+ // Config fetch failed — mount anyway as a safe fallback
67
+ console.warn('[search-agent] mountIfAllowed: config fetch failed, mounting anyway', err);
68
+ mountSearchAgent({ api, translator });
69
+ });
70
+ }
71
+
72
+ // ─── HTML templates ───────────────────────────────────────────────────────────
73
+
74
+ function buildFabHtml() {
75
+ return `
76
+ <button
77
+ id="search-agent-fab"
78
+ class="search-agent-fab"
79
+ aria-label="[[search-agent:fab-label]]"
80
+ title="[[search-agent:fab-label]]"
81
+ >
82
+ <i class="fa fa-comments" aria-hidden="true"></i>
83
+ </button>`;
84
+ }
85
+
86
+ function buildPanelHtml() {
87
+ return `
88
+ <div id="search-agent-panel" class="search-agent-panel" role="dialog" aria-modal="true" aria-label="[[search-agent:title]]" hidden>
89
+ <div class="search-agent-panel__header">
90
+ <span class="search-agent-panel__title">
91
+ <i class="fa fa-search"></i> [[search-agent:title]]
92
+ </span>
93
+ <button class="search-agent-panel__close" aria-label="[[search-agent:close-label]]" id="search-agent-close">
94
+ <i class="fa fa-times"></i>
95
+ </button>
96
+ </div>
97
+
98
+ <div class="search-agent-panel__body" id="search-agent-messages" aria-live="polite">
99
+ <div class="search-agent-bubble search-agent-bubble--bot">
100
+ [[search-agent:panel-greeting]]
101
+ </div>
102
+ </div>
103
+
104
+ <div class="search-agent-panel__footer">
105
+ <input
106
+ id="search-agent-input"
107
+ class="search-agent-input"
108
+ type="text"
109
+ placeholder="[[search-agent:input-placeholder]]"
110
+ maxlength="500"
111
+ autocomplete="off"
112
+ />
113
+ <button id="search-agent-send" class="search-agent-send" aria-label="[[search-agent:send-label]]">
114
+ <i class="fa fa-paper-plane"></i>
115
+ </button>
116
+ </div>
117
+ </div>`;
118
+ }
119
+
120
+ // ─── Mount ────────────────────────────────────────────────────────────────────
121
+
122
+ function mountSearchAgent({ api, translator }) {
123
+ console.log('[search-agent] mountSearchAgent: injecting FAB and chat panel into DOM');
124
+ // Avoid double-mounting if somehow called twice
125
+ if (document.getElementById('search-agent-fab')) {
126
+ console.log('[search-agent] mountSearchAgent: widget already mounted, skipping');
127
+ return;
128
+ }
129
+
130
+ const wrapper = document.createElement('div');
131
+ wrapper.className = 'search-agent-wrapper';
132
+
133
+ // Translate [[search-agent:key]] markers before inserting into the DOM
134
+ translator.translate(buildFabHtml() + buildPanelHtml(), function (translatedHtml) {
135
+ wrapper.innerHTML = translatedHtml;
136
+ document.body.appendChild(wrapper);
137
+
138
+ const fab = document.getElementById('search-agent-fab');
139
+ const panel = document.getElementById('search-agent-panel');
140
+ const closeBtn = document.getElementById('search-agent-close');
141
+ const input = document.getElementById('search-agent-input');
142
+ const sendBtn = document.getElementById('search-agent-send');
143
+ const messages = document.getElementById('search-agent-messages');
144
+
145
+ // ── Toggle panel ──────────────────────────────────────────────────────────
146
+ fab.addEventListener('click', () => {
147
+ const isOpen = !panel.hidden;
148
+ panel.hidden = isOpen;
149
+ fab.classList.toggle('search-agent-fab--active', !isOpen);
150
+ console.log(`[search-agent] FAB clicked — panel is now ${isOpen ? 'closed' : 'open'}`);
151
+ if (!isOpen) {
152
+ input.focus();
153
+ }
154
+ });
155
+
156
+ closeBtn.addEventListener('click', () => {
157
+ panel.hidden = true;
158
+ fab.classList.remove('search-agent-fab--active');
159
+ });
160
+
161
+ // Close on Escape
162
+ document.addEventListener('keydown', (e) => {
163
+ if (e.key === 'Escape' && !panel.hidden) {
164
+ panel.hidden = true;
165
+ fab.classList.remove('search-agent-fab--active');
166
+ }
167
+ });
168
+
169
+ // ── Submit on Enter or button click ───────────────────────────────────────
170
+ sendBtn.addEventListener('click', () => submitQuery({ api, translator, input, messages, sendBtn }));
171
+
172
+ input.addEventListener('keydown', (e) => {
173
+ if (e.key === 'Enter' && !e.shiftKey) {
174
+ e.preventDefault();
175
+ submitQuery({ api, translator, input, messages, sendBtn });
176
+ }
177
+ });
178
+ });
179
+ }
180
+
181
+ // ─── Chat logic ───────────────────────────────────────────────────────────────
182
+
183
+ async function submitQuery({ api, translator, input, messages, sendBtn }) {
184
+ const queryText = input.value.trim();
185
+ console.log(`[search-agent] submitQuery: user submitted query="${queryText}"`);
186
+ if (!queryText) {
187
+ console.log('[search-agent] submitQuery: empty input, ignoring');
188
+ return;
189
+ }
190
+
191
+ // Show user bubble
192
+ appendBubble(messages, 'user', escapeHtml(queryText));
193
+ input.value = '';
194
+ input.disabled = true;
195
+ sendBtn.disabled = true;
196
+
197
+ // Typing indicator
198
+ const thinkingId = 'sa-thinking-' + Date.now();
199
+ appendBubble(messages, 'bot', '<span class="search-agent-typing"><span></span><span></span><span></span></span>', thinkingId);
200
+ scrollToBottom(messages);
201
+
202
+ try {
203
+ console.log('[search-agent] submitQuery: sending request to /plugins/search-agent/query');
204
+ const response = await api.post('/plugins/search-agent/query', { query: queryText });
205
+ removeBubble(messages, thinkingId);
206
+
207
+ const results = response && response.results;
208
+ console.log(`[search-agent] submitQuery: received ${results ? results.length : 0} result(s)`);
209
+
210
+ if (!results || results.length === 0) {
211
+ translator.translate(`[[search-agent:no-results, ${escapeHtml(queryText)}]]`, function (msg) {
212
+ appendBubble(messages, 'bot', `<em>${msg}</em>`);
213
+ });
214
+ } else {
215
+ const listItems = results
216
+ .map(r => `<li><a href="${escapeHtml(r.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(r.title)}</a></li>`)
217
+ .join('');
218
+
219
+ translator.translate('[[search-agent:results-header]]', function (header) {
220
+ appendBubble(
221
+ messages,
222
+ 'bot',
223
+ `<p>${header}</p><ol class="search-agent-results">${listItems}</ol>`
224
+ );
225
+ });
226
+ }
227
+ } catch (err) {
228
+ console.log('[search-agent] submitQuery: request failed —', err);
229
+ removeBubble(messages, thinkingId);
230
+
231
+ let msgKey = 'error.server';
232
+ if (err && err.status === 403) {
233
+ msgKey = 'error.not-logged-in';
234
+ } else if (err && err.status === 400) {
235
+ msgKey = 'error.invalid-query';
236
+ }
237
+
238
+ translator.translate(`[[search-agent:${msgKey}]]`, function (msg) {
239
+ appendBubble(messages, 'bot error', `<em>${msg}</em>`);
240
+ });
241
+ } finally {
242
+ input.disabled = false;
243
+ sendBtn.disabled = false;
244
+ scrollToBottom(messages);
245
+ input.focus();
246
+ }
247
+ }
248
+
249
+ // ─── DOM utilities ────────────────────────────────────────────────────────────
250
+
251
+ function appendBubble(container, typeClass, html, id) {
252
+ const div = document.createElement('div');
253
+ div.className = `search-agent-bubble search-agent-bubble--${typeClass}`;
254
+ if (id) {
255
+ div.id = id;
256
+ }
257
+ div.innerHTML = html;
258
+ container.appendChild(div);
259
+ scrollToBottom(container);
260
+ }
261
+
262
+ function removeBubble(container, id) {
263
+ const el = document.getElementById(id);
264
+ if (el && container.contains(el)) {
265
+ container.removeChild(el);
266
+ }
267
+ }
268
+
269
+ function scrollToBottom(container) {
270
+ container.scrollTop = container.scrollHeight;
271
+ }
272
+
273
+ function escapeHtml(str) {
274
+ return String(str)
275
+ .replace(/&/g, '&amp;')
276
+ .replace(/</g, '&lt;')
277
+ .replace(/>/g, '&gt;')
278
+ .replace(/"/g, '&quot;')
279
+ .replace(/'/g, '&#039;');
280
+ }
281
+
package/renovate.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": [
3
+ "config:recommended"
4
+ ]
5
+ }
@@ -0,0 +1,374 @@
1
+ // =============================================================================
2
+ // Search Agent Plugin – styles
3
+ // =============================================================================
4
+ //
5
+ // Layout overview
6
+ // ---------------
7
+ // .search-agent-wrapper Fixed positioning host (bottom-left corner)
8
+ // .search-agent-fab Circular floating action button
9
+ // .search-agent-panel Chat panel (hidden by default)
10
+ // .search-agent-panel__header
11
+ // .search-agent-panel__body Scrollable message list
12
+ // .search-agent-panel__footer Input + send button row
13
+ //
14
+
15
+ // ── Variables ─────────────────────────────────────────────────────────────────
16
+
17
+ $sa-primary: #0f7ee8;
18
+ $sa-primary-dark: #0a5fad;
19
+ $sa-bg: #ffffff;
20
+ $sa-bg-header: $sa-primary;
21
+ $sa-border: #dde3ec;
22
+ $sa-bubble-user-bg: $sa-primary;
23
+ $sa-bubble-bot-bg: #f0f4f9;
24
+ $sa-text-light: #ffffff;
25
+ $sa-text-dark: #1a1a2e;
26
+ $sa-error-bg: #fdecea;
27
+ $sa-error-border: #f5c6cb;
28
+
29
+ $sa-panel-width: 360px;
30
+ $sa-panel-height: 500px;
31
+ $sa-fab-size: 56px;
32
+ $sa-fab-offset-x: 24px;
33
+ $sa-fab-offset-y: 24px;
34
+ $sa-border-radius: 16px;
35
+ $sa-z-index: 9000;
36
+
37
+ $sa-transition: 0.25s ease;
38
+
39
+ // ── Wrapper (positioning host) ────────────────────────────────────────────────
40
+
41
+ .search-agent-wrapper {
42
+ position: fixed;
43
+ bottom: $sa-fab-offset-y;
44
+ left: $sa-fab-offset-x;
45
+ z-index: $sa-z-index;
46
+ display: flex;
47
+ flex-direction: column-reverse; // panel grows upward from FAB
48
+ align-items: flex-start;
49
+ gap: 12px;
50
+ }
51
+
52
+ // ── Floating Action Button ─────────────────────────────────────────────────────
53
+
54
+ .search-agent-fab {
55
+ width: $sa-fab-size;
56
+ height: $sa-fab-size;
57
+ border-radius: 50%;
58
+ border: none;
59
+ background-color: $sa-primary;
60
+ color: $sa-text-light;
61
+ font-size: 22px;
62
+ cursor: pointer;
63
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
64
+ transition: background-color $sa-transition, transform $sa-transition, box-shadow $sa-transition;
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ flex-shrink: 0;
69
+
70
+ &:hover {
71
+ background-color: $sa-primary-dark;
72
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.30);
73
+ }
74
+
75
+ &:focus-visible {
76
+ outline: 3px solid $sa-primary;
77
+ outline-offset: 3px;
78
+ }
79
+
80
+ &--active {
81
+ transform: rotate(15deg);
82
+ background-color: $sa-primary-dark;
83
+
84
+ .fa-comments::before {
85
+ content: "\f075"; // solid comment bubble when open
86
+ }
87
+ }
88
+ }
89
+
90
+ // ── Chat Panel ────────────────────────────────────────────────────────────────
91
+
92
+ .search-agent-panel {
93
+ width: $sa-panel-width;
94
+ height: $sa-panel-height;
95
+ background-color: $sa-bg;
96
+ border-radius: $sa-border-radius;
97
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
98
+ border: 1px solid $sa-border;
99
+ display: flex;
100
+ flex-direction: column;
101
+ overflow: hidden;
102
+ transform-origin: bottom left;
103
+ animation: sa-panel-in $sa-transition both;
104
+
105
+ @keyframes sa-panel-in {
106
+ from { opacity: 0; transform: scale(0.92) translateY(8px); }
107
+ to { opacity: 1; transform: scale(1) translateY(0); }
108
+ }
109
+
110
+ // Responsive: use most of the screen on small viewports
111
+ @media (max-width: 420px) {
112
+ width: calc(100vw - #{$sa-fab-offset-x * 2});
113
+ height: 75vh;
114
+ }
115
+
116
+ &[hidden] {
117
+ display: none;
118
+ }
119
+
120
+ // ── Header ───────────────────────────────────────────────────────────────
121
+
122
+ &__header {
123
+ background-color: $sa-bg-header;
124
+ color: $sa-text-light;
125
+ padding: 12px 16px;
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: space-between;
129
+ flex-shrink: 0;
130
+ }
131
+
132
+ &__title {
133
+ font-size: 15px;
134
+ font-weight: 600;
135
+ letter-spacing: 0.3px;
136
+
137
+ .fa {
138
+ margin-right: 6px;
139
+ }
140
+ }
141
+
142
+ &__close {
143
+ background: none;
144
+ border: none;
145
+ color: $sa-text-light;
146
+ font-size: 16px;
147
+ cursor: pointer;
148
+ opacity: 0.8;
149
+ transition: opacity $sa-transition;
150
+ line-height: 1;
151
+ padding: 2px 4px;
152
+
153
+ &:hover { opacity: 1; }
154
+
155
+ &:focus-visible {
156
+ outline: 2px solid rgba(255, 255, 255, 0.7);
157
+ border-radius: 4px;
158
+ }
159
+ }
160
+
161
+ // ── Message body ──────────────────────────────────────────────────────────
162
+
163
+ &__body {
164
+ flex: 1;
165
+ overflow-y: auto;
166
+ padding: 14px 12px;
167
+ display: flex;
168
+ flex-direction: column;
169
+ gap: 10px;
170
+ scroll-behavior: smooth;
171
+ }
172
+
173
+ // ── Footer / input row ────────────────────────────────────────────────────
174
+
175
+ &__footer {
176
+ display: flex;
177
+ align-items: center;
178
+ gap: 8px;
179
+ padding: 10px 12px;
180
+ border-top: 1px solid $sa-border;
181
+ flex-shrink: 0;
182
+ }
183
+ }
184
+
185
+ // ── Input ────────────────────────────────────────────────────────────────────
186
+
187
+ .search-agent-input {
188
+ flex: 1;
189
+ border: 1px solid $sa-border;
190
+ border-radius: 20px;
191
+ padding: 8px 14px;
192
+ font-size: 14px;
193
+ color: $sa-text-dark;
194
+ outline: none;
195
+ transition: border-color $sa-transition, box-shadow $sa-transition;
196
+
197
+ &:focus {
198
+ border-color: $sa-primary;
199
+ box-shadow: 0 0 0 3px rgba($sa-primary, 0.15);
200
+ }
201
+
202
+ &:disabled {
203
+ background-color: #f7f8fa;
204
+ cursor: not-allowed;
205
+ }
206
+ }
207
+
208
+ // ── Send button ────────────────────────────────────────────────────────────────
209
+
210
+ .search-agent-send {
211
+ width: 38px;
212
+ height: 38px;
213
+ border-radius: 50%;
214
+ border: none;
215
+ background-color: $sa-primary;
216
+ color: $sa-text-light;
217
+ font-size: 14px;
218
+ cursor: pointer;
219
+ display: flex;
220
+ align-items: center;
221
+ justify-content: center;
222
+ flex-shrink: 0;
223
+ transition: background-color $sa-transition, transform $sa-transition;
224
+
225
+ &:hover {
226
+ background-color: $sa-primary-dark;
227
+ }
228
+
229
+ &:active {
230
+ transform: scale(0.92);
231
+ }
232
+
233
+ &:disabled {
234
+ background-color: lighten($sa-primary, 25%);
235
+ cursor: not-allowed;
236
+ }
237
+
238
+ &:focus-visible {
239
+ outline: 3px solid $sa-primary;
240
+ outline-offset: 2px;
241
+ }
242
+ }
243
+
244
+ // ── Chat bubbles ──────────────────────────────────────────────────────────────
245
+
246
+ .search-agent-bubble {
247
+ max-width: 92%;
248
+ padding: 9px 13px;
249
+ border-radius: 16px;
250
+ font-size: 14px;
251
+ line-height: 1.5;
252
+ word-wrap: break-word;
253
+
254
+ // Bot bubble – left-aligned
255
+ &--bot {
256
+ align-self: flex-start;
257
+ background-color: $sa-bubble-bot-bg;
258
+ color: $sa-text-dark;
259
+ border-bottom-left-radius: 4px;
260
+ }
261
+
262
+ // User bubble – right-aligned
263
+ &--user {
264
+ align-self: flex-end;
265
+ background-color: $sa-bubble-user-bg;
266
+ color: $sa-text-light;
267
+ border-bottom-right-radius: 4px;
268
+ }
269
+
270
+ // Error state
271
+ &--error {
272
+ align-self: flex-start;
273
+ background-color: $sa-error-bg;
274
+ border: 1px solid $sa-error-border;
275
+ color: darken($sa-error-border, 30%);
276
+ border-bottom-left-radius: 4px;
277
+ }
278
+
279
+ p { margin: 0 0 6px; &:last-child { margin-bottom: 0; } }
280
+ a { color: $sa-primary; text-decoration: underline; }
281
+ }
282
+
283
+ // ── Results ordered list ──────────────────────────────────────────────────────
284
+
285
+ .search-agent-results {
286
+ margin: 6px 0 0;
287
+ padding-left: 18px;
288
+ list-style: decimal;
289
+
290
+ li {
291
+ margin-bottom: 5px;
292
+ font-size: 13.5px;
293
+ }
294
+
295
+ a {
296
+ color: $sa-primary;
297
+ text-decoration: none;
298
+
299
+ &:hover { text-decoration: underline; }
300
+ }
301
+ }
302
+
303
+ // ── Typing indicator (three bouncing dots) ─────────────────────────────────────
304
+
305
+ .search-agent-typing {
306
+ display: inline-flex;
307
+ align-items: center;
308
+ gap: 4px;
309
+ height: 18px;
310
+
311
+ span {
312
+ width: 7px;
313
+ height: 7px;
314
+ border-radius: 50%;
315
+ background-color: darken($sa-bubble-bot-bg, 30%);
316
+ animation: sa-bounce 1.2s infinite ease-in-out;
317
+
318
+ &:nth-child(1) { animation-delay: 0s; }
319
+ &:nth-child(2) { animation-delay: 0.2s; }
320
+ &:nth-child(3) { animation-delay: 0.4s; }
321
+ }
322
+ }
323
+
324
+ @keyframes sa-bounce {
325
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
326
+ 40% { transform: scale(1); opacity: 1; }
327
+ }
328
+
329
+ // ── RTL support (Hebrew, Arabic, etc.) ───────────────────────────────────────
330
+ // NodeBB sets `dir="rtl"` on <html> for right-to-left languages.
331
+
332
+ [dir="rtl"] {
333
+ .search-agent-wrapper {
334
+ left: auto;
335
+ right: $sa-fab-offset-x;
336
+ align-items: flex-end;
337
+ }
338
+
339
+ .search-agent-panel {
340
+ transform-origin: bottom right;
341
+ }
342
+
343
+ .search-agent-panel__title {
344
+ .fa {
345
+ margin-right: 0;
346
+ margin-left: 6px;
347
+ }
348
+ }
349
+
350
+ .search-agent-bubble {
351
+ // Bot bubble: flip rounded corner to the right side
352
+ &--bot {
353
+ border-bottom-left-radius: $sa-border-radius;
354
+ border-bottom-right-radius: 4px;
355
+ }
356
+
357
+ // User bubble: flip rounded corner to the left side
358
+ &--user {
359
+ border-bottom-right-radius: $sa-border-radius;
360
+ border-bottom-left-radius: 4px;
361
+ }
362
+
363
+ &--error {
364
+ border-bottom-left-radius: $sa-border-radius;
365
+ border-bottom-right-radius: 4px;
366
+ }
367
+ }
368
+
369
+ .search-agent-results {
370
+ padding-left: 0;
371
+ padding-right: 18px;
372
+ }
373
+ }
374
+
@@ -0,0 +1,5 @@
1
+ <h1>Hello!</h1>
2
+
3
+ <p>This file is served by nodebb at domain.com/assets/plugins/nodebb-plugin-quickstart/static/samplefile.html</p>
4
+
5
+ Check plugin.json for the "staticDirs" property if you want to change the path.