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.
- package/.gitattributes +22 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/commitlint.config.js +26 -0
- package/eslint.config.mjs +10 -0
- package/languages/de/search-agent.json +15 -0
- package/languages/en-GB/search-agent.json +15 -0
- package/languages/en-US/search-agent.json +15 -0
- package/languages/he/search-agent.json +15 -0
- package/lib/controllers.js +69 -0
- package/lib/searchHandler.js +225 -0
- package/lib/similarity.js +125 -0
- package/library.js +98 -0
- package/nodebb-plugin-search-agent-0.0.1.tgz +0 -0
- package/package.json +51 -0
- package/plugin.json +29 -0
- package/plugins/quickstart/partials/sorted-list/form.tpl +10 -0
- package/plugins/quickstart/partials/sorted-list/item.tpl +12 -0
- package/plugins/search-agent.tpl +93 -0
- package/public/lib/acp-main.js +38 -0
- package/public/lib/admin.js +26 -0
- package/public/lib/main.js +281 -0
- package/renovate.json +5 -0
- package/scss/search-agent.scss +374 -0
- package/static/samplefile.html +5 -0
- package/templates/admin/plugins/search-agent.tpl +186 -0
- package/test/.eslintrc +9 -0
- package/test/index.js +128 -0
|
@@ -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, '&')
|
|
276
|
+
.replace(/</g, '<')
|
|
277
|
+
.replace(/>/g, '>')
|
|
278
|
+
.replace(/"/g, '"')
|
|
279
|
+
.replace(/'/g, ''');
|
|
280
|
+
}
|
|
281
|
+
|
package/renovate.json
ADDED
|
@@ -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
|
+
|