nodebb-plugin-pdf-secure2 1.3.6 → 1.3.7

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.
@@ -185,20 +185,21 @@ Controllers.handleChat = async function (req, res) {
185
185
  });
186
186
  } catch (err) {
187
187
  console.error('[PDF-Secure] Chat error:', err.message, err.status || '', err.code || '');
188
+ const quota = { used: rateResult.used, max: rateResult.max };
188
189
 
189
190
  if (err.message === 'File not found') {
190
- return res.status(404).json({ error: 'PDF bulunamadı.' });
191
+ return res.status(404).json({ error: 'PDF bulunamadı.', quota });
191
192
  }
192
193
  if (err.message === 'PDF too large for AI chat') {
193
- return res.status(413).json({ error: 'Bu PDF çok büyük. AI chat desteklemiyor.' });
194
+ return res.status(413).json({ error: 'Bu PDF çok büyük. AI chat desteklemiyor.', quota });
194
195
  }
195
196
  if (err.status === 429 || err.message.includes('rate limit') || err.message.includes('quota')) {
196
- return res.status(429).json({ error: 'AI servisi şu an yoğun. Lütfen birkaç saniye sonra tekrar deneyin.' });
197
+ return res.status(429).json({ error: 'AI servisi şu an yoğun. Lütfen birkaç saniye sonra tekrar deneyin.', quota });
197
198
  }
198
199
  if (err.status === 401 || err.status === 403 || err.message.includes('API key')) {
199
- return res.status(503).json({ error: 'AI servisi yapılandırma hatası. Yöneticiyle iletişime geçin.' });
200
+ return res.status(503).json({ error: 'AI servisi yapılandırma hatası. Yöneticiyle iletişime geçin.', quota });
200
201
  }
201
- return res.status(500).json({ error: 'AI yanıt veremedi. Lütfen tekrar deneyin.' });
202
+ return res.status(500).json({ error: 'AI yanıt veremedi. Lütfen tekrar deneyin.', quota });
202
203
  }
203
204
  };
204
205
 
@@ -7,10 +7,14 @@ const GeminiChat = module.exports;
7
7
 
8
8
  let ai = null;
9
9
 
10
- // In-memory cache for PDF base64 data (avoids re-reading from disk)
10
+ // In-memory cache for PDF base64 data (fallback, avoids re-reading from disk)
11
11
  const pdfDataCache = new Map(); // filename -> { base64, cachedAt }
12
12
  const PDF_DATA_TTL = 30 * 60 * 1000; // 30 minutes
13
13
 
14
+ // Gemini File API upload cache — upload once, reference many times
15
+ // Saves ~3M+ tokens per request by not sending inline base64 every time
16
+ const fileUploadCache = new Map(); // filename -> { fileUri, mimeType, cachedAt }
17
+
14
18
  const SYSTEM_INSTRUCTION = `Sen bir PDF döküman asistanısın. Kurallar:
15
19
  - Kullanıcının yazdığı dilde cevap ver
16
20
  - Önce kısa ve net cevapla, gerekirse detay ekle
@@ -82,7 +86,7 @@ const TIER_CONFIG = {
82
86
 
83
87
  const MODEL_NAME = 'gemini-2.5-flash';
84
88
 
85
- // Periodic cleanup
89
+ // Periodic cleanup of all caches
86
90
  const cleanupTimer = setInterval(() => {
87
91
  const now = Date.now();
88
92
  for (const [key, entry] of pdfDataCache.entries()) {
@@ -90,6 +94,11 @@ const cleanupTimer = setInterval(() => {
90
94
  pdfDataCache.delete(key);
91
95
  }
92
96
  }
97
+ for (const [key, entry] of fileUploadCache.entries()) {
98
+ if (now - entry.cachedAt > PDF_DATA_TTL) {
99
+ fileUploadCache.delete(key);
100
+ }
101
+ }
93
102
  }, 10 * 60 * 1000);
94
103
  cleanupTimer.unref();
95
104
 
@@ -112,7 +121,47 @@ GeminiChat.isAvailable = function () {
112
121
  return !!ai;
113
122
  };
114
123
 
115
- // Read PDF and cache base64 in memory
124
+ // Upload PDF to Gemini File API (or return cached reference)
125
+ // Returns { type: 'fileData', fileUri, mimeType } or { type: 'inlineData', mimeType, data }
126
+ async function getOrUploadPdf(filename) {
127
+ // Check file upload cache first
128
+ const cached = fileUploadCache.get(filename);
129
+ if (cached && Date.now() - cached.cachedAt < PDF_DATA_TTL) {
130
+ return { type: 'fileData', fileUri: cached.fileUri, mimeType: cached.mimeType };
131
+ }
132
+
133
+ const filePath = pdfHandler.resolveFilePath(filename);
134
+ if (!filePath || !fs.existsSync(filePath)) {
135
+ throw new Error('File not found');
136
+ }
137
+
138
+ const stats = await fs.promises.stat(filePath);
139
+ if (stats.size > MAX_FILE_SIZE) {
140
+ throw new Error('PDF too large for AI chat');
141
+ }
142
+
143
+ try {
144
+ // Upload to Gemini File API — file is stored server-side, only URI is sent per request
145
+ const uploadResult = await ai.files.upload({
146
+ file: filePath,
147
+ config: { mimeType: 'application/pdf' },
148
+ });
149
+
150
+ const fileUri = uploadResult.uri;
151
+ const mimeType = uploadResult.mimeType || 'application/pdf';
152
+ console.log('[PDF-Secure] File API upload OK:', filename, '→', fileUri);
153
+
154
+ fileUploadCache.set(filename, { fileUri, mimeType, cachedAt: Date.now() });
155
+ return { type: 'fileData', fileUri, mimeType };
156
+ } catch (uploadErr) {
157
+ // Fallback: use inline base64 (works but uses many tokens)
158
+ console.warn('[PDF-Secure] File API upload failed, falling back to inline:', uploadErr.message);
159
+ const base64 = await getPdfBase64(filename);
160
+ return { type: 'inlineData', mimeType: 'application/pdf', data: base64 };
161
+ }
162
+ }
163
+
164
+ // Read PDF and cache base64 in memory (fallback for File API failure)
116
165
  async function getPdfBase64(filename) {
117
166
  const cached = pdfDataCache.get(filename);
118
167
  if (cached && Date.now() - cached.cachedAt < PDF_DATA_TTL) {
@@ -142,7 +191,7 @@ GeminiChat.chat = async function (filename, question, history, tier) {
142
191
  }
143
192
 
144
193
  const config = TIER_CONFIG[tier] || TIER_CONFIG.premium;
145
- const base64Data = await getPdfBase64(filename);
194
+ const pdfRef = await getOrUploadPdf(filename);
146
195
 
147
196
  // Build conversation contents from history (trimmed to last N entries)
148
197
  const contents = [];
@@ -170,12 +219,16 @@ GeminiChat.chat = async function (filename, question, history, tier) {
170
219
  parts: [{ text: sanitizeHistoryText(question) }],
171
220
  });
172
221
 
173
- // Always use inline PDF single API call, no upload/cache overhead
174
- const inlineContents = [
222
+ // Build PDF partFile API reference (lightweight) or inline base64 (fallback)
223
+ const pdfPart = pdfRef.type === 'fileData'
224
+ ? { fileData: { fileUri: pdfRef.fileUri, mimeType: pdfRef.mimeType } }
225
+ : { inlineData: { mimeType: pdfRef.mimeType, data: pdfRef.data } };
226
+
227
+ const fullContents = [
175
228
  {
176
229
  role: 'user',
177
230
  parts: [
178
- { inlineData: { mimeType: 'application/pdf', data: base64Data } },
231
+ pdfPart,
179
232
  { text: 'I am sharing a PDF document with you. Please use it to answer my questions.' },
180
233
  ],
181
234
  },
@@ -188,7 +241,7 @@ GeminiChat.chat = async function (filename, question, history, tier) {
188
241
 
189
242
  const response = await ai.models.generateContent({
190
243
  model: MODEL_NAME,
191
- contents: inlineContents,
244
+ contents: fullContents,
192
245
  config: {
193
246
  systemInstruction: SYSTEM_INSTRUCTION,
194
247
  maxOutputTokens: config.maxOutputTokens,
@@ -215,7 +268,10 @@ GeminiChat.generateSuggestions = async function (filename) {
215
268
  return cached.suggestions;
216
269
  }
217
270
 
218
- const base64Data = await getPdfBase64(filename);
271
+ const pdfRef = await getOrUploadPdf(filename);
272
+ const pdfPart = pdfRef.type === 'fileData'
273
+ ? { fileData: { fileUri: pdfRef.fileUri, mimeType: pdfRef.mimeType } }
274
+ : { inlineData: { mimeType: pdfRef.mimeType, data: pdfRef.data } };
219
275
 
220
276
  const response = await ai.models.generateContent({
221
277
  model: MODEL_NAME,
@@ -223,7 +279,7 @@ GeminiChat.generateSuggestions = async function (filename) {
223
279
  {
224
280
  role: 'user',
225
281
  parts: [
226
- { inlineData: { mimeType: 'application/pdf', data: base64Data } },
282
+ pdfPart,
227
283
  { text: 'Bu PDF dokümanı için kullanıcının sorabileceği 5 adet akıllı ve spesifik soru öner. Soruları JSON array formatında döndür, başka hiçbir şey yazma. Örnek: ["Soru 1?", "Soru 2?"]' },
228
284
  ],
229
285
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure2",
3
- "version": "1.3.6",
3
+ "version": "1.3.7",
4
4
  "description": "Secure PDF viewer plugin for NodeBB - prevents downloading, enables canvas-only rendering with Premium group support",
5
5
  "main": "library.js",
6
6
  "repository": {
package/image.png DELETED
Binary file