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
package/.gitattributes
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Auto detect text files and perform LF normalization
|
|
2
|
+
* text=auto
|
|
3
|
+
|
|
4
|
+
# Custom for Visual Studio
|
|
5
|
+
*.cs diff=csharp
|
|
6
|
+
*.sln merge=union
|
|
7
|
+
*.csproj merge=union
|
|
8
|
+
*.vbproj merge=union
|
|
9
|
+
*.fsproj merge=union
|
|
10
|
+
*.dbproj merge=union
|
|
11
|
+
|
|
12
|
+
# Standard to msysgit
|
|
13
|
+
*.doc diff=astextplain
|
|
14
|
+
*.DOC diff=astextplain
|
|
15
|
+
*.docx diff=astextplain
|
|
16
|
+
*.DOCX diff=astextplain
|
|
17
|
+
*.dot diff=astextplain
|
|
18
|
+
*.DOT diff=astextplain
|
|
19
|
+
*.pdf diff=astextplain
|
|
20
|
+
*.PDF diff=astextplain
|
|
21
|
+
*.rtf diff=astextplain
|
|
22
|
+
*.RTF diff=astextplain
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016 NodeBB Inc. <sales@nodebb.org>
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# nodebb-plugin-search-agent
|
|
2
|
+
|
|
3
|
+
A NodeBB plugin that adds a **floating chat assistant** in the bottom-left corner of every forum page.
|
|
4
|
+
Users can type a natural-language question and receive a ranked list of relevant forum topic links — powered entirely by an in-process **TF-IDF cosine-similarity** engine (no external ML services required).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Floating Action Button (FAB)** — always visible in the bottom-left corner, out of the way of existing UI.
|
|
11
|
+
- **Chat panel** — opens upward from the FAB with a smooth animation.
|
|
12
|
+
- **Natural-language query** — accepts free-form questions, not just keywords.
|
|
13
|
+
- **TF-IDF similarity ranking** — titles weighted ×3 over body text; smoothed IDF over the indexed corpus.
|
|
14
|
+
- **In-memory index with TTL cache** — built once every 5 minutes; instant subsequent queries.
|
|
15
|
+
- **Admin settings page** — configure topic limit, max results, and guest access from the ACP.
|
|
16
|
+
- **Accessible** — ARIA roles, keyboard navigation, focus management, `Escape` to close.
|
|
17
|
+
- **Zero extra npm dependencies** — works on any NodeBB installation as-is.
|
|
18
|
+
- **i18n-ready** — translations for `en-US`, `en-GB`, and `de` included.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Architecture
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Browser NodeBB Server
|
|
26
|
+
────────────────────────────── ───────────────────────────────────────────
|
|
27
|
+
public/lib/main.js library.js
|
|
28
|
+
• Injects FAB + chat panel • static:app.load → registers admin route
|
|
29
|
+
• Sends POST on submit • static:api.routes → registers API route
|
|
30
|
+
• Renders result bubbles • filter:admin.header.build → ACP nav link
|
|
31
|
+
│ │
|
|
32
|
+
│ POST /api/v3/plugins/ │
|
|
33
|
+
│ search-agent/query │
|
|
34
|
+
└──────────────────────────────────►│
|
|
35
|
+
▼
|
|
36
|
+
lib/controllers.js
|
|
37
|
+
handleQuery()
|
|
38
|
+
│
|
|
39
|
+
▼
|
|
40
|
+
lib/searchHandler.js
|
|
41
|
+
getIndex() ──► cache (5 min TTL)
|
|
42
|
+
search()
|
|
43
|
+
│
|
|
44
|
+
▼
|
|
45
|
+
lib/similarity.js
|
|
46
|
+
buildIndex() (TF-IDF vectors)
|
|
47
|
+
query() (cosine similarity)
|
|
48
|
+
│
|
|
49
|
+
NodeBB database
|
|
50
|
+
db.getSortedSetRevRange('topics:recent')
|
|
51
|
+
topics.getTopicsFields()
|
|
52
|
+
posts.getPostsFields()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Similarity algorithm
|
|
56
|
+
|
|
57
|
+
1. **Tokenisation** – lowercase, strip HTML, remove punctuation, discard stop-words, keep tokens ≥ 3 chars.
|
|
58
|
+
2. **Document construction** – topic title repeated ×3 then concatenated with main-post body (title bias).
|
|
59
|
+
3. **TF** – raw term frequency within the document.
|
|
60
|
+
4. **IDF** – smoothed: `log((N+1) / (df+1)) + 1` to avoid zero-division on rare terms.
|
|
61
|
+
5. **TF-IDF vector** – sparse `Map<term, weight>` per document.
|
|
62
|
+
6. **Query vector** – raw TF of the query tokens (no IDF, since it is a single document).
|
|
63
|
+
7. **Cosine similarity** – dot product of query × doc divided by the product of their L2 norms.
|
|
64
|
+
8. **Ranking** – sort descending, return top-N (default 10).
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
### From the NodeBB Plugin Directory
|
|
71
|
+
|
|
72
|
+
Search for **Search Agent** in ACP → Extend → Plugins and click **Install**.
|
|
73
|
+
|
|
74
|
+
### Manual (development)
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Inside your NodeBB installation root
|
|
78
|
+
cd node_modules
|
|
79
|
+
git clone https://github.com/your-org/nodebb-plugin-search-agent
|
|
80
|
+
cd nodebb-plugin-search-agent
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then in the NodeBB ACP → **Extend → Plugins**, find **Search Agent** and click **Activate**, then **Rebuild & Restart**.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
Navigate to **ACP → Plugins → Search Agent**.
|
|
90
|
+
|
|
91
|
+
| Setting | Default | Description |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `topicLimit` | `500` | Maximum number of recent topics loaded into the index. |
|
|
94
|
+
| `maxResults` | `10` | Maximum results returned per query. |
|
|
95
|
+
| `guestsAllowed` | off | When enabled, guests (logged-out users) can also use the widget. |
|
|
96
|
+
|
|
97
|
+
Settings are stored under the key `search-agent` via `meta.settings`.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## API Reference
|
|
102
|
+
|
|
103
|
+
### `POST /api/v3/plugins/search-agent/query`
|
|
104
|
+
|
|
105
|
+
Requires a valid session or Bearer token (unless `guestsAllowed` is enabled).
|
|
106
|
+
|
|
107
|
+
**Request body**
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{ "query": "how do I reset my password?" }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Success response**
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"status": { "code": "ok", "message": "OK" },
|
|
118
|
+
"response": {
|
|
119
|
+
"results": [
|
|
120
|
+
{ "tid": 42, "title": "Password reset guide", "url": "/topic/42/password-reset-guide", "score": 0.71 },
|
|
121
|
+
{ "tid": 17, "title": "Account recovery options", "url": "/topic/17/account-recovery", "score": 0.54 }
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Error codes**
|
|
128
|
+
|
|
129
|
+
| HTTP | Meaning |
|
|
130
|
+
|---|---|
|
|
131
|
+
| 400 | Empty or too-long query |
|
|
132
|
+
| 401/403 | Not authenticated |
|
|
133
|
+
| 500 | Server-side error (check NodeBB logs) |
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## File Structure
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
nodebb-plugin-search-agent/
|
|
141
|
+
├── library.js Main plugin: registers hooks & routes
|
|
142
|
+
├── plugin.json Plugin manifest (hooks, assets, modules)
|
|
143
|
+
├── package.json
|
|
144
|
+
├── lib/
|
|
145
|
+
│ ├── controllers.js Route handlers (admin page + query API)
|
|
146
|
+
│ ├── searchHandler.js NodeBB DB integration + index caching
|
|
147
|
+
│ └── similarity.js Pure-JS TF-IDF engine
|
|
148
|
+
├── public/lib/
|
|
149
|
+
│ ├── main.js Frontend: FAB injection, chat panel, API calls
|
|
150
|
+
│ ├── admin.js ACP settings page module
|
|
151
|
+
│ └── acp-main.js ACP-bundled bootstrap script
|
|
152
|
+
├── scss/
|
|
153
|
+
│ └── search-agent.scss All styles: FAB, panel, bubbles, animations
|
|
154
|
+
├── templates/
|
|
155
|
+
│ └── admin/plugins/
|
|
156
|
+
│ └── search-agent.tpl ACP settings page template
|
|
157
|
+
└── languages/
|
|
158
|
+
├── en-US/search-agent.json
|
|
159
|
+
├── en-GB/search-agent.json
|
|
160
|
+
└── de/search-agent.json
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Design Decisions
|
|
166
|
+
|
|
167
|
+
**Why TF-IDF instead of a vector embedding model?**
|
|
168
|
+
External ML services (OpenAI, HuggingFace) require API keys, network calls, and add latency. A neural embedding approach also demands significant RAM for local inference. TF-IDF runs entirely in the Node.js process with zero memory or network overhead and is very fast (<10 ms per query on 500 topics), making it the right default for a self-hosted forum plugin.
|
|
169
|
+
|
|
170
|
+
**Why cache the index instead of building it per request?**
|
|
171
|
+
`topics.getTopicsFields` + `posts.getPostsFields` are database calls that add 50–200 ms. The index build itself adds another 20–50 ms. Caching amortises this to near zero. A 5-minute TTL means new topics appear within one cache window.
|
|
172
|
+
|
|
173
|
+
**Why title ×3 weight?**
|
|
174
|
+
Forum users almost always phrase questions using words that appear in topic titles. Over-weighting the body would dilute the signal for short replies and off-topic content in long threads.
|
|
175
|
+
|
|
176
|
+
**Why restrict the index to `topics:recent`?**
|
|
177
|
+
For large forums (100 k+ topics) building a full-corpus index at startup would consume excessive memory. The 500-topic default covers the most active/relevant content and can be raised in settings.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Local Development
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
# 1. Clone NodeBB
|
|
185
|
+
git clone https://github.com/NodeBB/NodeBB && cd NodeBB
|
|
186
|
+
npm install
|
|
187
|
+
|
|
188
|
+
# 2. Link the plugin
|
|
189
|
+
cd node_modules
|
|
190
|
+
git clone <this-repo> nodebb-plugin-search-agent
|
|
191
|
+
|
|
192
|
+
# 3. Start NodeBB
|
|
193
|
+
cd ../..
|
|
194
|
+
./nodebb setup # follow the setup wizard
|
|
195
|
+
./nodebb start
|
|
196
|
+
|
|
197
|
+
# 4. Activate the plugin in ACP and rebuild
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
|
205
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
extends: ['@commitlint/config-angular'],
|
|
5
|
+
rules: {
|
|
6
|
+
'header-max-length': [1, 'always', 72],
|
|
7
|
+
'type-enum': [
|
|
8
|
+
2,
|
|
9
|
+
'always',
|
|
10
|
+
[
|
|
11
|
+
'breaking',
|
|
12
|
+
'build',
|
|
13
|
+
'chore',
|
|
14
|
+
'ci',
|
|
15
|
+
'docs',
|
|
16
|
+
'feat',
|
|
17
|
+
'fix',
|
|
18
|
+
'perf',
|
|
19
|
+
'refactor',
|
|
20
|
+
'revert',
|
|
21
|
+
'style',
|
|
22
|
+
'test',
|
|
23
|
+
],
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Forum-Assistent",
|
|
3
|
+
"fab-label": "Forum-Assistent befragen",
|
|
4
|
+
"panel-greeting": "Hallo! Stelle eine Frage und ich finde passende Themen für dich.",
|
|
5
|
+
"input-placeholder": "Deine Frage eingeben…",
|
|
6
|
+
"send-label": "Senden",
|
|
7
|
+
"close-label": "Schließen",
|
|
8
|
+
"results-header": "Hier sind die relevantesten Themen:",
|
|
9
|
+
"no-results": "Keine passenden Themen für \"%1\" gefunden. Versuche eine andere Formulierung.",
|
|
10
|
+
"error.empty-query": "Bitte gib eine Frage ein, bevor du suchst.",
|
|
11
|
+
"error.query-too-long": "Deine Frage ist zu lang (max. 500 Zeichen).",
|
|
12
|
+
"error.server": "Serverfehler. Bitte versuche es erneut.",
|
|
13
|
+
"error.not-logged-in": "Du musst angemeldet sein, um den Forum-Assistenten zu nutzen.",
|
|
14
|
+
"error.invalid-query": "Deine Anfrage war ungültig."
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Forum Assistant",
|
|
3
|
+
"fab-label": "Ask the forum assistant",
|
|
4
|
+
"panel-greeting": "Hi! Ask me anything and I'll find relevant topics for you.",
|
|
5
|
+
"input-placeholder": "Type your question…",
|
|
6
|
+
"send-label": "Send",
|
|
7
|
+
"close-label": "Close",
|
|
8
|
+
"results-header": "Here are the most relevant topics:",
|
|
9
|
+
"no-results": "No matching topics found for \"%1\". Try rephrasing your question.",
|
|
10
|
+
"error.empty-query": "Please enter a question before searching.",
|
|
11
|
+
"error.query-too-long": "Your question is too long (max 500 characters).",
|
|
12
|
+
"error.server": "Something went wrong on the server. Please try again.",
|
|
13
|
+
"error.not-logged-in": "You must be logged in to use the forum assistant.",
|
|
14
|
+
"error.invalid-query": "Your query was invalid."
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Forum Assistant",
|
|
3
|
+
"fab-label": "Ask the forum assistant",
|
|
4
|
+
"panel-greeting": "Hi! Ask me anything and I'll find relevant topics for you.",
|
|
5
|
+
"input-placeholder": "Type your question…",
|
|
6
|
+
"send-label": "Send",
|
|
7
|
+
"close-label": "Close",
|
|
8
|
+
"results-header": "Here are the most relevant topics:",
|
|
9
|
+
"no-results": "No matching topics found for \"%1\". Try rephrasing your question.",
|
|
10
|
+
"error.empty-query": "Please enter a question before searching.",
|
|
11
|
+
"error.query-too-long": "Your question is too long (max 500 characters).",
|
|
12
|
+
"error.server": "Something went wrong on the server. Please try again.",
|
|
13
|
+
"error.not-logged-in": "You must be logged in to use the forum assistant.",
|
|
14
|
+
"error.invalid-query": "Your query was invalid."
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "עוזר הפורום",
|
|
3
|
+
"fab-label": "שאל את עוזר הפורום",
|
|
4
|
+
"panel-greeting": "שלום! שאל אותי כל דבר ואמצא עבורך נושאים רלוונטיים.",
|
|
5
|
+
"input-placeholder": "הקלד את שאלתך…",
|
|
6
|
+
"send-label": "שלח",
|
|
7
|
+
"close-label": "סגור",
|
|
8
|
+
"results-header": "הנה הנושאים הרלוונטיים ביותר:",
|
|
9
|
+
"no-results": "לא נמצאו נושאים תואמים עבור \"%1\". נסה לנסח מחדש את שאלתך.",
|
|
10
|
+
"error.empty-query": "אנא הזן שאלה לפני החיפוש.",
|
|
11
|
+
"error.query-too-long": "שאלתך ארוכה מדי (מקסימום 500 תווים).",
|
|
12
|
+
"error.server": "משהו השתבש בשרת. אנא נסה שוב.",
|
|
13
|
+
"error.not-logged-in": "יש להתחבר כדי להשתמש בעוזר הפורום.",
|
|
14
|
+
"error.invalid-query": "השאילתה שלך לא הייתה תקינה."
|
|
15
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { searchTopics, getSettings } = require('./searchHandler');
|
|
4
|
+
|
|
5
|
+
const controllers = {};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /admin/plugins/search-agent
|
|
9
|
+
* Renders the admin settings page.
|
|
10
|
+
*/
|
|
11
|
+
controllers.renderAdminPage = async function (req, res) {
|
|
12
|
+
res.render('admin/plugins/search-agent', {});
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* POST /api/v3/plugins/search-agent/query
|
|
17
|
+
* Body: { query: string }
|
|
18
|
+
* Returns: { results: [{ tid, title, url }] }
|
|
19
|
+
*/
|
|
20
|
+
controllers.handleQuery = async function (req, res, helpers) {
|
|
21
|
+
// Enforce visibility setting before doing anything else
|
|
22
|
+
const settings = await getSettings();
|
|
23
|
+
if (settings.visibleTo === 'admins') {
|
|
24
|
+
const User = require.main.require('./src/user');
|
|
25
|
+
const isAdmin = await User.isAdministrator(req.uid);
|
|
26
|
+
if (!isAdmin) {
|
|
27
|
+
return helpers.formatApiResponse(403, res, new Error('This feature is restricted to administrators.'));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const queryText = (req.body && req.body.query)
|
|
32
|
+
? String(req.body.query).trim()
|
|
33
|
+
: '';
|
|
34
|
+
|
|
35
|
+
if (!queryText) {
|
|
36
|
+
return helpers.formatApiResponse(400, res, new Error('Missing or empty "query" field.'));
|
|
37
|
+
}
|
|
38
|
+
if (queryText.length > 500) {
|
|
39
|
+
return helpers.formatApiResponse(400, res, new Error('Query exceeds maximum length of 500 characters.'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const results = await searchTopics(queryText);
|
|
44
|
+
helpers.formatApiResponse(200, res, { results });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
require.main.require('winston').error(`[search-agent] handleQuery error: ${err.message}`);
|
|
47
|
+
helpers.formatApiResponse(500, res, new Error('Internal error while processing your query.'));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* GET /api/v3/plugins/search-agent/config
|
|
53
|
+
* Returns the public configuration the client widget needs to decide
|
|
54
|
+
* whether to render itself (visibleTo, guestsAllowed).
|
|
55
|
+
* No authentication required — guests need to know if the widget is for them.
|
|
56
|
+
*/
|
|
57
|
+
controllers.getConfig = async function (req, res, helpers) {
|
|
58
|
+
try {
|
|
59
|
+
const settings = await getSettings();
|
|
60
|
+
helpers.formatApiResponse(200, res, {
|
|
61
|
+
visibleTo: settings.visibleTo,
|
|
62
|
+
guestsAllowed: settings.guestsAllowed,
|
|
63
|
+
});
|
|
64
|
+
} catch (err) {
|
|
65
|
+
helpers.formatApiResponse(500, res, new Error('Failed to load plugin configuration.'));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
module.exports = controllers;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const { buildIndex, query } = require('./similarity');
|
|
5
|
+
|
|
6
|
+
// ─── In-memory cache ──────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
let cachedIndex = null;
|
|
9
|
+
let cachedTopicMap = null;
|
|
10
|
+
let cacheTs = 0;
|
|
11
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
12
|
+
|
|
13
|
+
function invalidateCache() {
|
|
14
|
+
cachedIndex = null;
|
|
15
|
+
cachedTopicMap = null;
|
|
16
|
+
cacheTs = 0;
|
|
17
|
+
require.main.require('winston').info('[search-agent] Topic index cache invalidated.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Settings ─────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
async function getSettings() {
|
|
23
|
+
const meta = require.main.require('./src/meta');
|
|
24
|
+
const raw = (await meta.settings.get('search-agent')) || {};
|
|
25
|
+
return {
|
|
26
|
+
topicLimit: Math.max(50, parseInt(raw.topicLimit, 10) || 500),
|
|
27
|
+
maxResults: Math.min(20, Math.max(1, parseInt(raw.maxResults, 10) || 10)),
|
|
28
|
+
aiEnabled: raw.aiEnabled === 'on',
|
|
29
|
+
openaiApiKey: (raw.openaiApiKey || '').trim(),
|
|
30
|
+
openaiModel: (raw.openaiModel || 'gpt-4o-mini').trim(),
|
|
31
|
+
// How many TF-IDF candidates to send to AI for re-ranking
|
|
32
|
+
aiCandidates: Math.min(30, Math.max(5, parseInt(raw.aiCandidates, 10) || 20)),
|
|
33
|
+
// Visibility: 'all' = all logged-in users, 'admins' = administrators only
|
|
34
|
+
visibleTo: raw.visibleTo || 'all',
|
|
35
|
+
// Whether guests (non-logged-in users) may use the widget
|
|
36
|
+
guestsAllowed: raw.guestsAllowed === 'on',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Topic fetching ───────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
async function fetchTopics(limit) {
|
|
43
|
+
const db = require.main.require('./src/database');
|
|
44
|
+
const Topics = require.main.require('./src/topics');
|
|
45
|
+
const Posts = require.main.require('./src/posts');
|
|
46
|
+
|
|
47
|
+
// Fetch most-recent topics first
|
|
48
|
+
const tids = await db.getSortedSetRevRange('topics:tid', 0, limit - 1);
|
|
49
|
+
if (!tids || tids.length === 0) return [];
|
|
50
|
+
|
|
51
|
+
const topics = await Topics.getTopicsFields(
|
|
52
|
+
tids,
|
|
53
|
+
['tid', 'title', 'slug', 'deleted', 'mainPid']
|
|
54
|
+
);
|
|
55
|
+
const active = topics.filter(t => t && t.tid && !t.deleted && t.title);
|
|
56
|
+
|
|
57
|
+
// Enrich with main-post body for better TF-IDF recall
|
|
58
|
+
await Promise.all(active.map(async (t) => {
|
|
59
|
+
if (t.mainPid) {
|
|
60
|
+
t.mainPostContent = (await Posts.getPostField(t.mainPid, 'content')) || '';
|
|
61
|
+
} else {
|
|
62
|
+
t.mainPostContent = '';
|
|
63
|
+
}
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
return active;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getIndex(topicLimit) {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
if (cachedIndex && (now - cacheTs) < CACHE_TTL_MS) {
|
|
72
|
+
return { index: cachedIndex, topicMap: cachedTopicMap };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
require.main.require('winston').info('[search-agent] Rebuilding topic index…');
|
|
76
|
+
const topics = await fetchTopics(topicLimit);
|
|
77
|
+
cachedIndex = buildIndex(topics);
|
|
78
|
+
cachedTopicMap = Object.fromEntries(topics.map(t => [String(t.tid), t]));
|
|
79
|
+
cacheTs = now;
|
|
80
|
+
require.main.require('winston').info(`[search-agent] Index built: ${topics.length} topics.`);
|
|
81
|
+
|
|
82
|
+
return { index: cachedIndex, topicMap: cachedTopicMap };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── OpenAI helper ────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function callOpenAI(apiKey, model, messages) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const body = JSON.stringify({ model, messages, temperature: 0 });
|
|
90
|
+
const options = {
|
|
91
|
+
hostname: 'api.openai.com',
|
|
92
|
+
path: '/v1/chat/completions',
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
97
|
+
'Content-Length': Buffer.byteLength(body),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const req = https.request(options, (res) => {
|
|
102
|
+
let data = '';
|
|
103
|
+
res.on('data', chunk => { data += chunk; });
|
|
104
|
+
res.on('end', () => {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(data);
|
|
107
|
+
if (parsed.error) return reject(new Error(`OpenAI: ${parsed.error.message}`));
|
|
108
|
+
resolve(parsed);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
reject(e);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
req.on('error', reject);
|
|
116
|
+
req.setTimeout(20000, () => {
|
|
117
|
+
req.destroy(new Error('OpenAI request timed out after 20 s'));
|
|
118
|
+
});
|
|
119
|
+
req.write(body);
|
|
120
|
+
req.end();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Send TF-IDF candidates to OpenAI and ask it to pick the most relevant ones,
|
|
126
|
+
* ordered by relevance. Falls back to original TF-IDF order on any error.
|
|
127
|
+
*/
|
|
128
|
+
async function reRankWithAI(queryText, candidates, topicMap, apiKey, model, maxResults) {
|
|
129
|
+
const candidateList = candidates
|
|
130
|
+
.map((c, i) => `${i + 1}. [tid:${c.tid}] ${(topicMap[String(c.tid)] || {}).title || ''}`)
|
|
131
|
+
.join('\n');
|
|
132
|
+
|
|
133
|
+
const systemPrompt =
|
|
134
|
+
'You are a forum search assistant. ' +
|
|
135
|
+
'Given a user question and a numbered list of forum topic titles, ' +
|
|
136
|
+
'respond with ONLY a JSON array of the tid values (integers after "tid:") ' +
|
|
137
|
+
'for the topics that actually answer the question, ordered from most to least relevant. ' +
|
|
138
|
+
'Include only truly relevant topics. Example response: [12, 5, 33]';
|
|
139
|
+
|
|
140
|
+
const userMessage =
|
|
141
|
+
`User question: "${queryText}"\n\nForum topics:\n${candidateList}`;
|
|
142
|
+
|
|
143
|
+
const response = await callOpenAI(apiKey, model, [
|
|
144
|
+
{ role: 'system', content: systemPrompt },
|
|
145
|
+
{ role: 'user', content: userMessage },
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
const content = (response.choices[0].message.content || '').trim();
|
|
149
|
+
|
|
150
|
+
// Robustly extract a JSON integer array from the response
|
|
151
|
+
const match = content.match(/\[[\d,\s]+\]/);
|
|
152
|
+
if (!match) {
|
|
153
|
+
throw new Error(`Unexpected AI response format: ${content.slice(0, 100)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const rankedTids = JSON.parse(match[0]);
|
|
157
|
+
const candidateByTid = Object.fromEntries(candidates.map(c => [c.tid, c]));
|
|
158
|
+
|
|
159
|
+
return rankedTids
|
|
160
|
+
.map(tid => candidateByTid[tid])
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.slice(0, maxResults);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Search forum topics for the given query text.
|
|
169
|
+
* Uses TF-IDF for candidate selection and optionally OpenAI for re-ranking.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} queryText
|
|
172
|
+
* @returns {Promise<{ tid: number|string, title: string, url: string }[]>}
|
|
173
|
+
*/
|
|
174
|
+
async function searchTopics(queryText) {
|
|
175
|
+
const winston = require.main.require('winston');
|
|
176
|
+
const settings = await getSettings();
|
|
177
|
+
const { index, topicMap } = await getIndex(settings.topicLimit);
|
|
178
|
+
|
|
179
|
+
winston.info(`[search-agent] Query: "${queryText}" | index size: ${index.length} topics | aiEnabled: ${settings.aiEnabled && !!settings.openaiApiKey}`);
|
|
180
|
+
|
|
181
|
+
const useAI = settings.aiEnabled && settings.openaiApiKey;
|
|
182
|
+
// Fetch more candidates when AI will re-rank them
|
|
183
|
+
const candidateCount = useAI ? settings.aiCandidates : settings.maxResults;
|
|
184
|
+
|
|
185
|
+
const { tokenize } = require('./similarity');
|
|
186
|
+
const queryTokens = tokenize(queryText);
|
|
187
|
+
winston.info(`[search-agent] Query tokens: [${queryTokens.join(', ')}]`);
|
|
188
|
+
|
|
189
|
+
const candidates = query(queryText, index, candidateCount);
|
|
190
|
+
winston.info(`[search-agent] TF-IDF candidates (${candidates.length}): ${JSON.stringify(candidates.map(c => ({ tid: c.tid, title: (topicMap[String(c.tid)] || {}).title, score: c.score.toFixed(4) })))}`);
|
|
191
|
+
|
|
192
|
+
if (candidates.length === 0) {
|
|
193
|
+
winston.info('[search-agent] No candidates found — returning empty results.');
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let ranked = candidates;
|
|
198
|
+
|
|
199
|
+
if (useAI) {
|
|
200
|
+
try {
|
|
201
|
+
ranked = await reRankWithAI(
|
|
202
|
+
queryText,
|
|
203
|
+
candidates,
|
|
204
|
+
topicMap,
|
|
205
|
+
settings.openaiApiKey,
|
|
206
|
+
settings.openaiModel,
|
|
207
|
+
settings.maxResults
|
|
208
|
+
);
|
|
209
|
+
winston.info(`[search-agent] AI re-ranked to ${ranked.length} results.`);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
winston.warn(`[search-agent] AI re-rank failed, falling back to TF-IDF: ${err.message}`);
|
|
212
|
+
ranked = candidates.slice(0, settings.maxResults);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const results = ranked.map(r => ({
|
|
217
|
+
tid: r.tid,
|
|
218
|
+
title: (topicMap[String(r.tid)] || {}).title || `Topic ${r.tid}`,
|
|
219
|
+
url: `/topic/${(topicMap[String(r.tid)] || {}).slug || r.tid}`,
|
|
220
|
+
}));
|
|
221
|
+
winston.info(`[search-agent] Final results: ${JSON.stringify(results.map(r => r.title))}`);
|
|
222
|
+
return results;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { searchTopics, invalidateCache, getSettings };
|