nodebb-plugin-openai 1.2.0 → 1.3.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.
@@ -1,3 +1,5 @@
1
1
  {
2
- "error.need-x-reputation-to-mention": "You need at least %1 reputation for mentioning chatgpt"
2
+ "error.need-x-reputation-to-mention": "You need at least %1 reputation for mentioning chatgpt",
3
+ "summarize-topic": "Summarize Topic",
4
+ "topic-summary": "Topic Summary"
3
5
  }
@@ -1,3 +1,5 @@
1
1
  {
2
- "error.need-x-reputation-to-mention": "You need at least %1 reputation for mentioning chatgpt"
2
+ "error.need-x-reputation-to-mention": "You need at least %1 reputation for mentioning chatgpt",
3
+ "summarize-topic": "Summarize Topic",
4
+ "topic-summary": "Topic Summary"
3
5
  }
package/lib/summary.js ADDED
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const db = require.main.require('./src/database');
4
+ const batch = require.main.require('./src/batch');
5
+ const topics = require.main.require('./src/topics');
6
+ const posts = require.main.require('./src/posts');
7
+ const user = require.main.require('./src/user');
8
+
9
+ function formatPosts(posts) {
10
+ return posts
11
+ .map((post) => `User ${post.username}:\n${post.content}`)
12
+ .join('\n---\n');
13
+ }
14
+
15
+ function chunkPosts(posts, maxTokensPerChunk = 3000) {
16
+ const chunks = [];
17
+ let currentChunk = [];
18
+ let currentTokens = 0;
19
+
20
+ // eslint-disable-next-line no-restricted-syntax
21
+ for (const post of posts) {
22
+ const estimatedTokens = Math.ceil(post.content.length / 4) + 10;
23
+ if (currentTokens + estimatedTokens > maxTokensPerChunk) {
24
+ chunks.push(currentChunk);
25
+ currentChunk = [];
26
+ currentTokens = 0;
27
+ }
28
+ currentChunk.push(post);
29
+ currentTokens += estimatedTokens;
30
+ }
31
+
32
+ if (currentChunk.length > 0) {
33
+ chunks.push(currentChunk);
34
+ }
35
+
36
+ return chunks;
37
+ }
38
+
39
+ async function summarizeChunk(chunk, openai, settings) {
40
+ const threadText = formatPosts(chunk);
41
+ if (!threadText) {
42
+ return '';
43
+ }
44
+ const response = await openai.chat.completions.create({
45
+ model: settings.model || 'gpt-3.5-turbo',
46
+ messages: [
47
+ {
48
+ role: 'system',
49
+ content: 'You summarize discussion forum threads into concise summaries.',
50
+ },
51
+ {
52
+ role: 'user',
53
+ content: `Summarize the following discussion thread:\n\n${threadText}`,
54
+ },
55
+ ],
56
+ temperature: 0.5,
57
+ });
58
+
59
+ return response.choices[0].message.content.trim();
60
+ }
61
+
62
+ exports.summarizeTopic = async function(tid, openai, settings) {
63
+ const allPids = await topics.getPids(tid);
64
+
65
+ const userMap = {};
66
+
67
+ const chunkSummaries = [];
68
+
69
+ await batch.processArray(allPids, async function (pids) {
70
+ let postData = await posts.getPostsFields(pids, ['uid', 'content', 'deleted']);
71
+ postData = postData.filter((p) => !p.deleted);
72
+ const missingUids = [];
73
+ postData.forEach((p) => {
74
+ if (!userMap.hasOwnProperty(p.uid)) {
75
+ missingUids.push(p.uid)
76
+ }
77
+ });
78
+ const userData = await user.getUsersFields(missingUids, ['username']);
79
+ userData.forEach((u) => {
80
+ userMap[u.uid] = u.displayname;
81
+ });
82
+ postData.forEach((p) => {
83
+ p.username = userMap[p.uid];
84
+ });
85
+
86
+ const chunks = chunkPosts(postData);
87
+
88
+ chunkSummaries.push(...await Promise.all(chunks.map(async (chunk) => {
89
+ return summarizeChunk(chunk, openai, settings)
90
+ })));
91
+ }, {
92
+ batch: 500,
93
+ });
94
+
95
+ if (chunkSummaries.length === 1) {
96
+ return chunkSummaries[0];
97
+ }
98
+ // Final summary from all summaries
99
+ const finalInput = chunkSummaries.join("\n\n");
100
+ if (!finalInput) {
101
+ return '';
102
+ }
103
+ const finalResponse = await openai.chat.completions.create({
104
+ model: settings.model || 'gpt-3.5-turbo',
105
+ messages: [
106
+ {
107
+ role: 'system',
108
+ content: 'You are an assistant that summarizes forum thread summaries into a single cohesive summary.',
109
+ },
110
+ {
111
+ role: 'user',
112
+ content: `Here are summaries of parts of a forum discussion:\n\n${finalInput}\n\nPlease write a final, concise summary of the full discussion.`,
113
+ },
114
+ ],
115
+ temperature: 0.5,
116
+ });
117
+
118
+ return finalResponse.choices[0].message.content.trim();
119
+ };
120
+
121
+ exports.clearTopicSummary = async function(tids) {
122
+ await db.deleteObjectFields(tids.map((tid) => `topic:${tid}`), ['openai:summary']);
123
+ };
package/library.js CHANGED
@@ -16,6 +16,8 @@ const api = require.main.require('./src/api');
16
16
  const privileges = require.main.require('./src/privileges');
17
17
  const groups = require.main.require('./src/groups');
18
18
  const sockets = require.main.require('./src/socket.io');
19
+ const socketPlugins = require.main.require('./src/socket.io/plugins');
20
+ const summary = require('./lib/summary');
19
21
 
20
22
  const plugin = module.exports;
21
23
 
@@ -166,21 +168,21 @@ plugin.actionMessagingSave = async function (hookData) {
166
168
  }
167
169
  };
168
170
 
169
- async function canUseOpenAI(uid, settings) {
170
- if (!await checkReputation(uid, settings)) {
171
+ async function canUseOpenAI(uid, settings, silent = false) {
172
+ if (!await checkReputation(uid, settings, silent)) {
171
173
  return false;
172
174
  }
173
- if (!await checkGroupMembership(uid, settings)) {
175
+ if (!await checkGroupMembership(uid, settings, silent)) {
174
176
  return false;
175
177
  }
176
178
  return true;
177
179
  }
178
180
 
179
- async function checkReputation(uid, settings) {
181
+ async function checkReputation(uid, settings, silent) {
180
182
  const reputation = await user.getUserField(uid, 'reputation');
181
183
  const hasEnoughRep = parseInt(settings.minimumReputation, 10) === 0 || parseInt(reputation, 10) >= parseInt(settings.minimumReputation, 10);
182
184
 
183
- if (!hasEnoughRep) {
185
+ if (!hasEnoughRep && !silent) {
184
186
  sockets.server.in(`uid_${uid}`).emit('event:alert', {
185
187
  type: 'danger',
186
188
  title: '[[global:alert.error]]',
@@ -190,7 +192,7 @@ async function checkReputation(uid, settings) {
190
192
  return hasEnoughRep;
191
193
  }
192
194
 
193
- async function checkGroupMembership(uid, settings) {
195
+ async function checkGroupMembership(uid, settings, silent) {
194
196
  let allowedGroups = [];
195
197
  try {
196
198
  allowedGroups = JSON.parse(settings.allowedGroups) || [];
@@ -205,7 +207,7 @@ async function checkGroupMembership(uid, settings) {
205
207
 
206
208
  const isMembers = await groups.isMemberOfGroups(uid, allowedGroups);
207
209
  const memberOfAny = isMembers.includes(true);
208
- if (!memberOfAny) {
210
+ if (!memberOfAny && !silent) {
209
211
  sockets.server.in(`uid_${uid}`).emit('event:alert', {
210
212
  type: 'danger',
211
213
  title: '[[global:alert.error]]',
@@ -262,3 +264,67 @@ plugin.addAdminNavigation = (header) => {
262
264
  return header;
263
265
  };
264
266
 
267
+ plugin.filterTopicThreadTools = async (hookData) => {
268
+ if (!await canUseOpenAI(hookData.uid, await getSettings(), true)) {
269
+ return hookData;
270
+ }
271
+ hookData.tools.push({
272
+ class: 'openai-summarize-topic',
273
+ icon: 'fa-wand-magic-sparkles',
274
+ title: '[[openai:summarize-topic]]',
275
+ });
276
+ return hookData;
277
+ };
278
+
279
+ plugin.actionTopicReply = async (hookData) => {
280
+ await summary.clearTopicSummary([hookData.post.tid]);
281
+ };
282
+
283
+ plugin.actionPostEdit = async (hookData) => {
284
+ await summary.clearTopicSummary([hookData.post.tid]);
285
+ };
286
+
287
+ plugin.actionPostsPurge = async (hookData) => {
288
+ const { posts } = hookData;
289
+ const uniqTids = [...new Set(posts.map(p => p.tid))];
290
+ await summary.clearTopicSummary(uniqTids);
291
+ };
292
+
293
+ plugin.actionPostRestore = async (hookData) => {
294
+ await summary.clearTopicSummary([hookData.post.tid]);
295
+ };
296
+
297
+ plugin.actionPostDelete = async (hookData) => {
298
+ await summary.clearTopicSummary([hookData.post.tid]);
299
+ };
300
+
301
+ plugin.actionPostMove = async (hookData) => {
302
+ await summary.clearTopicSummary([hookData.post.tid, hookData.tid]);
303
+ };
304
+
305
+ plugin.actionPostChangeOwner = async (hookData) => {
306
+ const { posts } = hookData;
307
+ const uniqTids = [...new Set(posts.map(p => p.tid))];
308
+ await summary.clearTopicSummary(uniqTids);
309
+ };
310
+
311
+ socketPlugins.openai = {};
312
+
313
+ socketPlugins.openai.summarizeTopic = async function (socket, data) {
314
+ const { tid } = data;
315
+ if (!await privileges.topics.can('topics:read', tid, socket.uid)) {
316
+ throw new Error('[[error:no-privileges]]');
317
+ }
318
+ const settings = await getSettings();
319
+ if (!await canUseOpenAI(socket.uid, settings)) {
320
+ return;
321
+ }
322
+
323
+ let openaiSummary = await topics.getTopicField(tid, 'openai:summary');
324
+ if (openaiSummary) {
325
+ return openaiSummary;
326
+ }
327
+ openaiSummary = await summary.summarizeTopic(tid, openai, settings);
328
+ await topics.setTopicField(tid, 'openai:summary', openaiSummary);
329
+ return openaiSummary;
330
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-openai",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "A NodeBB OpenAI plugin",
5
5
  "main": "library.js",
6
6
  "repository": {
package/plugin.json CHANGED
@@ -6,7 +6,19 @@
6
6
  { "hook": "static:app.load", "method": "init" },
7
7
  { "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
8
8
  { "hook": "action:mentions.notify", "method": "actionMentionsNotify" },
9
- { "hook": "action:messaging.save", "method": "actionMessagingSave" }
9
+ { "hook": "action:messaging.save", "method": "actionMessagingSave" },
10
+ { "hook": "filter:topic.thread_tools", "method": "filterTopicThreadTools" },
11
+
12
+ { "hook": "action:topic.reply", "method": "actionTopicReply" },
13
+ { "hook": "action:post.edit", "method": "actionPostEdit" },
14
+ { "hook": "action:posts.purge", "method": "actionPostsPurge" },
15
+ { "hook": "action:post.restore", "method": "actionPostRestore" },
16
+ { "hook": "action:post.delete", "method": "actionPostDelete" },
17
+ { "hook": "action:post.move", "method": "actionPostMove" },
18
+ { "hook": "action:post.changeOwner", "method": "actionPostChangeOwner" }
19
+ ],
20
+ "scripts": [
21
+ "./public/lib/main.js"
10
22
  ],
11
23
  "modules": {
12
24
  "../admin/plugins/openai.js": "./public/lib/admin.js"
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ $('document').ready(function () {
4
+
5
+ function alertType(type, message) {
6
+ require(['alerts'], function (alerts) {
7
+ alerts[type](message);
8
+ });
9
+ }
10
+
11
+ $(window).on('action:topic.tools.load', function () {
12
+ $('.openai-summarize-topic').on('click', summarizeTopic);
13
+ });
14
+
15
+ function summarizeTopic() {
16
+ const tid = ajaxify.data.tid;
17
+ require(['bootbox'], function (bootbox) {
18
+ const modal = bootbox.dialog({
19
+ title: '[[openai:topic-summary]]',
20
+ message: `<div class="openai-summarize-topic"><div class="loading text-center"><i class="fa-solid fa-spinner fa-spin fa-2x"></i></div></div>`,
21
+ size: 'large',
22
+ buttons: {
23
+ ok: {
24
+ label: 'OK',
25
+ className: 'btn-primary',
26
+ },
27
+ },
28
+ });
29
+ socket.emit('plugins.openai.summarizeTopic', { tid }, function (err, data) {
30
+ if (err) {
31
+ return alertType('error', err.message);
32
+ }
33
+ if (data) {
34
+ modal.find('.openai-summarize-topic').text(data);
35
+ } else {
36
+ modal.modal('hide');
37
+ }
38
+ });
39
+ });
40
+ }
41
+ });
Binary file
@@ -42,7 +42,7 @@
42
42
  </div>
43
43
  <div class="">
44
44
  <label class="form-label" for="systemPrompt">System Prompt</label>
45
- <textarea class="form-control" id="systemPrompt" name="systemPrompt" title="System prompt" placeholder="You are a helpful assistant"></textarea>
45
+ <textarea class="form-control" id="systemPrompt" name="systemPrompt" title="System prompt" placeholder="You are a helpful assistant" rows="8"></textarea>
46
46
  </div>
47
47
  </div>
48
48