nodebb-plugin-pdf-secure2 1.4.3 → 1.4.5

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.
@@ -5,7 +5,17 @@
5
5
  "WebFetch(domain:github.com)",
6
6
  "Bash(dir:*)",
7
7
  "Bash(npm pack:*)",
8
- "Bash(tar:*)"
8
+ "Bash(tar:*)",
9
+ "Bash(grep:*)",
10
+ "Bash(find /c/Users/kadir/OneDrive/Masaüstü/Projeler/nodebb-plugin-pdf-secure/nodebb-plugin-pdf-secure -type f \\\\\\(-name *.conf -o -name *.config -o -name *nginx* -o -name *apache* \\\\\\))",
11
+ "Bash(xargs ls:*)",
12
+ "WebSearch",
13
+ "WebFetch(domain:community.nodebb.org)",
14
+ "Bash(cp /tmp/pdf-secure-143/package/package.json ./package.json)",
15
+ "Bash(cp /tmp/pdf-secure-143/package/library.js ./library.js)",
16
+ "Bash(cp /tmp/pdf-secure-143/package/lib/controllers.js ./lib/controllers.js)",
17
+ "Bash(cp /tmp/pdf-secure-143/package/lib/gemini-chat.js ./lib/gemini-chat.js)",
18
+ "Bash(cp /tmp/pdf-secure-143/package/static/lib/admin.js ./static/lib/admin.js)"
9
19
  ]
10
20
  }
11
21
  }
@@ -265,7 +265,8 @@ Controllers.handleChat = async function (req, res) {
265
265
  }
266
266
 
267
267
  // Body validation
268
- const { filename, question, history } = req.body;
268
+ const { filename, question, history, detailMode } = req.body;
269
+ const useDetailMode = tier === 'vip' && detailMode === true;
269
270
 
270
271
  if (!filename || typeof filename !== 'string') {
271
272
  return res.status(400).json({ error: 'Missing or invalid filename' });
@@ -312,7 +313,7 @@ Controllers.handleChat = async function (req, res) {
312
313
  }
313
314
 
314
315
  try {
315
- const result = await geminiChat.chat(safeName, trimmedQuestion, history || [], tier);
316
+ const result = await geminiChat.chat(safeName, trimmedQuestion, history || [], tier, useDetailMode);
316
317
  // Record actual token usage after successful AI response
317
318
  // Sanitize: clamp to [0, 1000000] to prevent NaN/negative/absurd values
318
319
  const tokensUsed = Math.max(0, Math.min(parseInt(result.tokensUsed, 10) || 0, 1000000));
@@ -395,7 +396,7 @@ Controllers.getSuggestions = async function (req, res) {
395
396
  }
396
397
 
397
398
  try {
398
- const suggestions = await geminiChat.generateSuggestions(safeName);
399
+ const suggestions = await geminiChat.generateSuggestions(safeName, tier);
399
400
  const quotaUsage = await getQuotaUsage(req.uid, tier);
400
401
  return res.json({ suggestions, quota: quotaUsage });
401
402
  } catch (err) {
@@ -38,13 +38,24 @@ ${SECURITY_RULES}`;
38
38
 
39
39
  const SYSTEM_INSTRUCTION_VIP = `Sen bir PDF döküman asistanı ve ders öğretmenisin. Bu döküman bir üniversite ders materyalidir. Kurallar:
40
40
  - Kullanıcının yazdığı dilde cevap ver
41
- - Soruya göre yanıt uzunluğunu ayarla: basit sorulara kısa (2-3 cümle), karmaşık sorulara daha detaylı (1-2 paragraf) cevap ver. Asla gereksiz uzatma
42
- - Sadece cevabı değil, "neden böyle?" sorusunu da cevapla — kavramı bağlamına oturtur şekilde öğretici yaklaş
43
- - Açıklamalarını örneklerle ve analojilerle zenginleştir ama kısa tut
41
+ - VARSAYILAN: Kısa, net, yüksek sinyalli cevap ver. Basit soru → 1-3 cümle. Orta zorluk 1 kısa paragraf. Karmaşık yapılandırılmış ama gereksiz detaysız
42
+ - Sadece cevabı değil "neden böyle?" sorusunu da kısa cevapla
43
+ - Madde ve başlık formatını kullan, gereksiz giriş cümleleri yazma
44
+ - Kullanıcı açıkça "detaylı", "daha fazla", "örnek ver" demezse derinleşme
45
+ - Matematiksel işlemlerde sadece kritik adımları göster
46
+ - Bilgiyi doğrudan PDF'ten al, sayfa/bölüm numarası belirt
47
+ - Dokümandaki konularla ilgili sorularda genel bilginle de destekle, ancak PDF'te olmayan bilgiyi "(Not: Bu bilgi doğrudan dokümanda geçmemektedir)" şeklinde belirt
48
+ - Tamamen alakasız konularda "Bu konu dökümanın kapsamı dışındadır" de
49
+ ${SECURITY_RULES}`;
50
+
51
+ const SYSTEM_INSTRUCTION_VIP_DETAILED = `Sen bir PDF döküman asistanı ve ders öğretmenisin. Bu döküman bir üniversite ders materyalidir. Kurallar:
52
+ - Kullanıcının yazdığı dilde cevap ver
53
+ - DETAYLI MOD AKTİF: Derin analiz, yapısal breakdown ve örneklerle zengin cevap ver
54
+ - Kavramları bağlamına oturtarak öğretici yaklaşımla açıkla
55
+ - Açıklamalarını örnekler ve analojilerle zenginleştir
56
+ - Karşılaştırma tabloları, kavram haritaları, ezber teknikleri ve sınav stratejilerini uygun gördüğünde kullan
57
+ - Matematiksel işlemleri adım adım çöz
44
58
  - Yanıtlarını madde, başlık ve yapısal format kullanarak düzenli sun
45
- - Karşılaştırma tabloları, kavram haritaları, ezber teknikleri ve sınav stratejilerini SADECE kullanıcı istediğinde ver
46
- - Kullanıcı "detaylı anlat", "daha fazla açıkla", "örnek ver" derse o zaman genişlet
47
- - Matematiksel işlemleri adım adım çöz, sadece kritik adımları göster
48
59
  - Bilgiyi doğrudan PDF'ten al, sayfa/bölüm numarası belirt
49
60
  - Dokümandaki konularla ilgili sorularda genel bilginle de destekle, ancak PDF'te olmayan bilgiyi "(Not: Bu bilgi doğrudan dokümanda geçmemektedir)" şeklinde belirt
50
61
  - Tamamen alakasız konularda "Bu konu dökümanın kapsamı dışındadır" de
@@ -113,7 +124,7 @@ function sanitizeHistoryText(text) {
113
124
 
114
125
  // Tier-based configuration
115
126
  const TIER_CONFIG = {
116
- vip: { maxHistory: 30, maxOutputTokens: 4096, recentFullMessages: 6 },
127
+ vip: { maxHistory: 30, maxOutputTokens: 2048, detailedMaxOutputTokens: 4096, recentFullMessages: 6 },
117
128
  premium: { maxHistory: 20, maxOutputTokens: 2048, recentFullMessages: 4 },
118
129
  };
119
130
 
@@ -249,7 +260,7 @@ function isSummaryRequest(question, history) {
249
260
  return SUMMARY_PATTERNS.some((p) => p.test(question));
250
261
  }
251
262
 
252
- GeminiChat.chat = async function (filename, question, history, tier) {
263
+ GeminiChat.chat = async function (filename, question, history, tier, detailMode) {
253
264
  if (!ai) {
254
265
  throw new Error('AI chat is not configured');
255
266
  }
@@ -320,21 +331,30 @@ GeminiChat.chat = async function (filename, question, history, tier) {
320
331
  ];
321
332
 
322
333
  // Use admin-configured prompt if set, otherwise fall back to defaults
323
- let systemInstruction = tier === 'vip'
324
- ? (customPrompts.vip || SYSTEM_INSTRUCTION_VIP)
325
- : (customPrompts.premium || SYSTEM_INSTRUCTION_PREMIUM);
334
+ let systemInstruction;
335
+ if (tier === 'vip') {
336
+ if (customPrompts.vip) {
337
+ systemInstruction = customPrompts.vip;
338
+ } else {
339
+ systemInstruction = detailMode ? SYSTEM_INSTRUCTION_VIP_DETAILED : SYSTEM_INSTRUCTION_VIP;
340
+ }
341
+ } else {
342
+ systemInstruction = customPrompts.premium || SYSTEM_INSTRUCTION_PREMIUM;
343
+ }
326
344
 
327
345
  // Always append security rules if not already present (admin can't bypass)
328
346
  if (!systemInstruction.includes('Güvenlik kuralları')) {
329
347
  systemInstruction += '\n' + SECURITY_RULES;
330
348
  }
331
349
 
350
+ const maxOutputTokens = (tier === 'vip' && detailMode) ? config.detailedMaxOutputTokens : config.maxOutputTokens;
351
+
332
352
  const response = await ai.models.generateContent({
333
353
  model: MODEL_NAME,
334
354
  contents: fullContents,
335
355
  config: {
336
356
  systemInstruction,
337
- maxOutputTokens: config.maxOutputTokens,
357
+ maxOutputTokens,
338
358
  },
339
359
  });
340
360
 
@@ -368,18 +388,20 @@ GeminiChat.chat = async function (filename, question, history, tier) {
368
388
  };
369
389
 
370
390
  // Generate AI-powered question suggestions for a PDF
371
- GeminiChat.generateSuggestions = async function (filename) {
391
+ GeminiChat.generateSuggestions = async function (filename, tier) {
372
392
  if (!ai) {
373
393
  throw new Error('AI chat is not configured');
374
394
  }
375
395
 
376
- // Check cache
377
- const cached = suggestionsCache.get(filename);
396
+ // Cache key includes tier (VIP gets 3, Premium gets 5)
397
+ const cacheKey = filename + '::' + (tier || 'premium');
398
+ const cached = suggestionsCache.get(cacheKey);
378
399
  if (cached && Date.now() - cached.cachedAt < SUGGESTIONS_TTL) {
379
400
  return cached.suggestions;
380
401
  }
381
402
 
382
- const pdfRef = await getOrUploadPdf(filename, 'premium');
403
+ const count = tier === 'vip' ? 3 : 5;
404
+ const pdfRef = await getOrUploadPdf(filename, tier || 'premium');
383
405
  const pdfPart = pdfRef.type === 'fileData'
384
406
  ? { fileData: { fileUri: pdfRef.fileUri, mimeType: pdfRef.mimeType } }
385
407
  : { inlineData: { mimeType: pdfRef.mimeType, data: pdfRef.data } };
@@ -391,7 +413,13 @@ GeminiChat.generateSuggestions = async function (filename) {
391
413
  role: 'user',
392
414
  parts: [
393
415
  pdfPart,
394
- { 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?"]' },
416
+ { text: `Bu PDF dokümanını analiz et. Kullanıcının sorabileceği ${count} adet SPESİFİK ve İÇERİĞE ÖZEL soru öner.
417
+ Kurallar:
418
+ - Her soru dokümandaki belirli bir kavram, bölüm veya bilgiye referans vermeli
419
+ - "Bu döküman ne hakkında?" gibi genel sorular YASAK
420
+ - Farklı açılardan sor: 1) özet/kapsam, 2) analiz/karşılaştırma, 3) uygulama/örnek
421
+ - Soruları JSON array formatında döndür, başka hiçbir şey yazma
422
+ Örnek: ["Soru 1?", "Soru 2?"]` },
395
423
  ],
396
424
  },
397
425
  ],
@@ -414,13 +442,13 @@ GeminiChat.generateSuggestions = async function (filename) {
414
442
  // Sanitize and limit
415
443
  suggestions = suggestions
416
444
  .filter(s => typeof s === 'string' && s.length > 0)
417
- .slice(0, 5)
445
+ .slice(0, count)
418
446
  .map(s => s.slice(0, 200));
419
447
  } catch (err) {
420
448
  // Fallback: return empty, client will keep defaults
421
449
  suggestions = [];
422
450
  }
423
451
 
424
- suggestionsCache.set(filename, { suggestions, cachedAt: Date.now() });
452
+ suggestionsCache.set(cacheKey, { suggestions, cachedAt: Date.now() });
425
453
  return suggestions;
426
454
  };
@@ -53,18 +53,18 @@ NonceStore.validate = function (nonce, uid) {
53
53
  return null;
54
54
  }
55
55
 
56
- // Delete immediately (single-use)
57
- store.delete(nonce);
58
-
59
- // Check UID match
56
+ // Validate BEFORE deleting — prevents DoS via nonce consumption with wrong UID
60
57
  if (data.uid !== uid) {
61
58
  return null;
62
59
  }
63
60
 
64
61
  // Check TTL
65
62
  if (Date.now() - data.createdAt > NONCE_TTL) {
63
+ store.delete(nonce); // Expired, clean up
66
64
  return null;
67
65
  }
68
66
 
67
+ // All checks passed — delete now (single-use)
68
+ store.delete(nonce);
69
69
  return data; // Includes encKey and encIv for AES-256-GCM
70
70
  };
@@ -36,7 +36,6 @@ PdfHandler.resolveFilePath = function (filename) {
36
36
  const uploadPath = nconf.get('upload_path') || path.join(nconf.get('base_dir'), 'public', 'uploads');
37
37
  const filePath = path.join(uploadPath, 'files', safeName);
38
38
 
39
- // Verify the resolved path is still within the upload directory
40
39
  const resolvedPath = path.resolve(filePath);
41
40
  const resolvedUploadDir = path.resolve(path.join(uploadPath, 'files'));
42
41
  if (!resolvedPath.startsWith(resolvedUploadDir)) {
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const privileges = require.main.require('./src/privileges');
4
+ const topics = require.main.require('./src/topics');
5
+ const posts = require.main.require('./src/posts');
6
+ const groups = require.main.require('./src/groups');
7
+ const db = require.main.require('./src/database');
8
+
9
+ const TopicAccess = module.exports;
10
+
11
+ /**
12
+ * Validate that a user has access to a PDF through a specific topic.
13
+ * Checks: 1) User can read the topic, 2) The PDF filename exists in the topic's posts.
14
+ * Admin/Global Moderators bypass all checks.
15
+ *
16
+ * @param {number} uid - User ID
17
+ * @param {number|string} tid - Topic ID
18
+ * @param {string} filename - Sanitized PDF filename (basename only)
19
+ * @returns {Promise<{allowed: boolean, reason?: string}>}
20
+ */
21
+ TopicAccess.validate = async function (uid, tid, filename) {
22
+ // Require valid tid
23
+ tid = parseInt(tid, 10);
24
+ if (!tid || isNaN(tid) || tid <= 0) {
25
+ return { allowed: false, reason: 'Missing or invalid topic ID' };
26
+ }
27
+
28
+ try {
29
+ // Admin/Global Moderator bypass
30
+ const [isAdmin, isGlobalMod] = await Promise.all([
31
+ groups.isMember(uid, 'administrators'),
32
+ groups.isMember(uid, 'Global Moderators'),
33
+ ]);
34
+ if (isAdmin || isGlobalMod) {
35
+ return { allowed: true };
36
+ }
37
+
38
+ // Check if user can read the topic (NodeBB privilege system)
39
+ const canRead = await privileges.topics.can('topics:read', tid, uid);
40
+ if (!canRead) {
41
+ return { allowed: false, reason: 'Access denied' };
42
+ }
43
+
44
+ // Verify the PDF filename exists in one of the topic's posts
45
+ const exists = await TopicAccess.pdfExistsInTopic(tid, filename);
46
+ if (!exists) {
47
+ return { allowed: false, reason: 'Access denied' };
48
+ }
49
+
50
+ return { allowed: true };
51
+ } catch (err) {
52
+ // DB error, topic not found, etc. — deny by default
53
+ return { allowed: false, reason: 'Access check failed' };
54
+ }
55
+ };
56
+
57
+ /**
58
+ * Check if a PDF filename is referenced in any post of a topic.
59
+ * Searches both the main post and all reply posts.
60
+ *
61
+ * @param {number} tid - Topic ID
62
+ * @param {string} filename - PDF filename to search for
63
+ * @returns {Promise<boolean>}
64
+ */
65
+ TopicAccess.pdfExistsInTopic = async function (tid, filename) {
66
+ // Get the topic's main post ID
67
+ const topicData = await topics.getTopicFields(tid, ['mainPid']);
68
+ if (!topicData || !topicData.mainPid) {
69
+ return false;
70
+ }
71
+
72
+ // Get all post IDs in this topic (replies)
73
+ const replyPids = await db.getSortedSetRange('tid:' + tid + ':posts', 0, -1);
74
+ const allPids = [topicData.mainPid, ...replyPids].filter(Boolean);
75
+
76
+ if (allPids.length === 0) {
77
+ return false;
78
+ }
79
+
80
+ // Get raw content of all posts
81
+ const postsData = await posts.getPostsFields(allPids, ['content']);
82
+
83
+ // Escape filename for regex safety, also match URL-encoded variant
84
+ // (post content may store "Özel Döküman.pdf" as "%C3%96zel%20D%C3%B6k%C3%BCman.pdf")
85
+ const escaped = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
+ const encodedEscaped = encodeURIComponent(filename).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ const pattern = new RegExp('(' + escaped + '|' + encodedEscaped + ')', 'i');
88
+
89
+ for (const post of postsData) {
90
+ if (post && post.content && pattern.test(post.content)) {
91
+ return true;
92
+ }
93
+ }
94
+
95
+ return false;
96
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure2",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
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/plugin.json CHANGED
@@ -26,6 +26,10 @@
26
26
  {
27
27
  "hook": "filter:parse.post",
28
28
  "method": "transformPdfLinks"
29
+ },
30
+ {
31
+ "hook": "filter:parse.raw",
32
+ "method": "transformPdfLinks"
29
33
  }
30
34
  ],
31
35
  "staticDirs": {
@@ -2731,16 +2731,28 @@
2731
2731
  document.getElementById('fullscreenBtn').onclick = () => toggleFullscreen();
2732
2732
 
2733
2733
 
2734
- // Mouse wheel zoom with Ctrl (debounced, clamped 0.5x-5x)
2735
- let zoomTimeout;
2734
+ // Mouse wheel / touchpad pinch zoom with Ctrl (clamped 0.5x-5x)
2735
+ let zoomAccumulator = 0;
2736
+ let zoomRafId = null;
2736
2737
  container.addEventListener('wheel', (e) => {
2737
2738
  if (!e.ctrlKey) return;
2738
2739
  e.preventDefault();
2739
- clearTimeout(zoomTimeout);
2740
- const delta = e.deltaY < 0 ? 0.1 : -0.1;
2741
- zoomTimeout = setTimeout(() => {
2742
- pdfViewer.currentScale = Math.max(0.5, Math.min(5, pdfViewer.currentScale + delta));
2743
- }, 30);
2740
+
2741
+ // Touchpad pinch sends small deltaY values; mouse wheel sends large ones (100+)
2742
+ // Normalize: clamp large mouse wheel jumps, keep touchpad's fine granularity
2743
+ const raw = e.deltaY;
2744
+ const clamped = Math.max(-10, Math.min(10, raw));
2745
+ const sensitivity = 0.008;
2746
+ zoomAccumulator -= clamped * sensitivity;
2747
+
2748
+ if (!zoomRafId) {
2749
+ zoomRafId = requestAnimationFrame(() => {
2750
+ const newScale = pdfViewer.currentScale * (1 + zoomAccumulator);
2751
+ pdfViewer.currentScale = Math.max(0.5, Math.min(5, newScale));
2752
+ zoomAccumulator = 0;
2753
+ zoomRafId = null;
2754
+ });
2755
+ }
2744
2756
  }, { passive: false });
2745
2757
 
2746
2758
  console.log('PDF Viewer Ready');
@@ -1079,6 +1079,108 @@
1079
1079
  margin-right: 2px;
1080
1080
  }
1081
1081
 
1082
+ /* AI message action buttons (copy, regenerate) */
1083
+ .chatMsgActions {
1084
+ display: flex;
1085
+ gap: 8px;
1086
+ margin-top: 6px;
1087
+ opacity: 0;
1088
+ transition: opacity 0.15s;
1089
+ }
1090
+ .chatMsg.ai:hover .chatMsgActions { opacity: 1; }
1091
+ .chatMsgAction {
1092
+ background: none;
1093
+ border: none;
1094
+ color: var(--text-secondary);
1095
+ font-size: 11px;
1096
+ cursor: pointer;
1097
+ display: flex;
1098
+ align-items: center;
1099
+ gap: 3px;
1100
+ padding: 2px 4px;
1101
+ border-radius: 4px;
1102
+ transition: color 0.15s, background 0.15s;
1103
+ }
1104
+ .chatMsgAction:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); }
1105
+ .chatMsgAction.copied { color: #4ade80; }
1106
+ .chatMsgAction svg { width: 12px; height: 12px; fill: currentColor; }
1107
+
1108
+ /* AI badge timestamp */
1109
+ .aiBadgeTime { font-weight: 400; opacity: 0.6; }
1110
+
1111
+ /* Scroll to bottom button */
1112
+ .chatScrollDown {
1113
+ position: absolute;
1114
+ bottom: 80px;
1115
+ left: 50%;
1116
+ transform: translateX(-50%);
1117
+ width: 32px; height: 32px;
1118
+ border-radius: 50%;
1119
+ background: var(--bg-secondary);
1120
+ border: 1px solid var(--border-color);
1121
+ color: var(--text-primary);
1122
+ font-size: 16px;
1123
+ cursor: pointer;
1124
+ opacity: 0;
1125
+ pointer-events: none;
1126
+ transition: opacity 0.2s;
1127
+ z-index: 5;
1128
+ display: flex;
1129
+ align-items: center;
1130
+ justify-content: center;
1131
+ }
1132
+ .chatScrollDown:hover { background: var(--bg-tertiary); }
1133
+ .chatScrollDown.visible { opacity: 1; pointer-events: auto; }
1134
+
1135
+ /* Blockquote in AI messages */
1136
+ .chatMsg blockquote {
1137
+ border-left: 3px solid var(--accent);
1138
+ margin: 6px 0;
1139
+ padding: 4px 10px;
1140
+ color: var(--text-secondary);
1141
+ font-style: italic;
1142
+ background: rgba(255,255,255,0.03);
1143
+ border-radius: 0 4px 4px 0;
1144
+ }
1145
+ body[data-tier="vip"] .chatMsg blockquote { border-left-color: #ffd700; }
1146
+
1147
+ /* Table overflow scroll hint */
1148
+ .chatMsg .tableWrap.scrollable::after {
1149
+ content: '';
1150
+ position: absolute;
1151
+ top: 0; right: 0; bottom: 0;
1152
+ width: 20px;
1153
+ background: linear-gradient(to right, transparent, var(--bg-tertiary));
1154
+ pointer-events: none;
1155
+ }
1156
+
1157
+ /* Loading elapsed timer */
1158
+ .chatLoadingElapsed { font-size: 10px; color: var(--text-secondary); opacity: 0.5; margin-left: 4px; }
1159
+
1160
+ /* Detail mode toggle */
1161
+ .vipTipsRow { display: flex; justify-content: space-between; align-items: center; }
1162
+ .detailToggle { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
1163
+ .detailToggle input { display: none; }
1164
+ .detailToggleSlider {
1165
+ width: 28px; height: 16px;
1166
+ background: rgba(255,255,255,0.15);
1167
+ border-radius: 8px;
1168
+ position: relative;
1169
+ transition: background 0.2s;
1170
+ }
1171
+ .detailToggleSlider::after {
1172
+ content: '';
1173
+ position: absolute; left: 2px; top: 2px;
1174
+ width: 12px; height: 12px;
1175
+ background: var(--text-secondary);
1176
+ border-radius: 50%;
1177
+ transition: transform 0.2s, background 0.2s;
1178
+ }
1179
+ .detailToggle input:checked + .detailToggleSlider { background: rgba(255,215,0,0.3); }
1180
+ .detailToggle input:checked + .detailToggleSlider::after { transform: translateX(12px); background: #ffd700; }
1181
+ .detailToggleText { font-size: 10px; color: var(--text-secondary); font-weight: 500; transition: color 0.2s; }
1182
+ .detailToggle input:checked ~ .detailToggleText { color: #ffd700; }
1183
+
1082
1184
  /* Character counter */
1083
1185
  .chatCharCount {
1084
1186
  font-size: 10px;
@@ -3011,7 +3113,14 @@
3011
3113
  <button class="closeBtn" id="closeChatSidebar">&times;</button>
3012
3114
  </div>
3013
3115
  <div class="vipTipsBanner" id="vipTipsBanner">
3014
- <div class="vipTipsLabel">VIP — Benden isteyebileceklerin:</div>
3116
+ <div class="vipTipsRow">
3117
+ <div class="vipTipsLabel">VIP — Benden isteyebileceklerin:</div>
3118
+ <label class="detailToggle" id="detailToggleLabel">
3119
+ <input type="checkbox" id="detailToggle">
3120
+ <span class="detailToggleSlider"></span>
3121
+ <span class="detailToggleText">Detaylı</span>
3122
+ </label>
3123
+ </div>
3015
3124
  <div class="vipTipsItems">
3016
3125
  <span class="vipTip">detayli aciklama</span>
3017
3126
  <span class="vipTip">ornek ver</span>
@@ -3021,6 +3130,7 @@
3021
3130
  </div>
3022
3131
  </div>
3023
3132
  <div id="chatMessages"></div>
3133
+ <button id="chatScrollDown" class="chatScrollDown">&#x2193;</button>
3024
3134
  <div class="chatCharCount" id="chatCharCount"></div>
3025
3135
  <div class="chatQuotaBar" id="chatQuotaBar">
3026
3136
  <div class="chatQuotaTrack"><div class="chatQuotaFill" id="chatQuotaFill"></div></div>
@@ -3976,6 +4086,17 @@
3976
4086
  document.body.setAttribute('data-tier', 'vip');
3977
4087
  }
3978
4088
 
4089
+ // Detail mode toggle (VIP only)
4090
+ var chatDetailMode = false;
4091
+ var detailToggleEl = document.getElementById('detailToggle');
4092
+ if (chatIsVip && detailToggleEl) {
4093
+ try { chatDetailMode = localStorage.getItem('pdfChat_detailMode') === 'true'; detailToggleEl.checked = chatDetailMode; } catch (e) { /* localStorage unavailable */ }
4094
+ detailToggleEl.addEventListener('change', function () {
4095
+ chatDetailMode = detailToggleEl.checked;
4096
+ try { localStorage.setItem('pdfChat_detailMode', String(chatDetailMode)); } catch (e) { /* ignore */ }
4097
+ });
4098
+ }
4099
+
3979
4100
  // Non-premium: hide input area entirely and show PRO badge
3980
4101
  if (!chatIsPremium) {
3981
4102
  document.getElementById('chatInputArea').style.display = 'none';
@@ -4010,6 +4131,18 @@
4010
4131
  };
4011
4132
  }
4012
4133
 
4134
+ // Scroll-to-bottom button
4135
+ var chatScrollDownBtn = document.getElementById('chatScrollDown');
4136
+ if (chatScrollDownBtn) {
4137
+ chatMessagesEl.addEventListener('scroll', function () {
4138
+ var atBottom = chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 60;
4139
+ chatScrollDownBtn.classList.toggle('visible', !atBottom);
4140
+ });
4141
+ chatScrollDownBtn.onclick = function () {
4142
+ chatMessagesEl.scrollTo({ top: chatMessagesEl.scrollHeight, behavior: 'smooth' });
4143
+ };
4144
+ }
4145
+
4013
4146
  // MutationObserver: prevent dangerous elements in chat (DevTools injection defense)
4014
4147
  var dangerousTags = ['A', 'SCRIPT', 'IFRAME', 'FORM', 'OBJECT', 'EMBED', 'LINK'];
4015
4148
  new MutationObserver(function (mutations) {
@@ -4067,7 +4200,7 @@
4067
4200
  var prompt = vipTipPrompts[chip.textContent.trim()];
4068
4201
  if (prompt && chatInputEl) {
4069
4202
  chatInputEl.value = prompt;
4070
- chatInputEl.focus();
4203
+ sendChatMessage();
4071
4204
  }
4072
4205
  };
4073
4206
  });
@@ -4160,6 +4293,17 @@
4160
4293
  i++; continue;
4161
4294
  }
4162
4295
 
4296
+ // Blockquote: > text
4297
+ if (trimmed.startsWith('> ')) {
4298
+ flushPara();
4299
+ var bq = document.createElement('blockquote');
4300
+ var bqTokens = tokenizeInline(trimmed.slice(2));
4301
+ for (var t = 0; t < bqTokens.length; t++) bq.appendChild(bqTokens[t]);
4302
+ parent.appendChild(bq);
4303
+ hasOutput = true;
4304
+ i++; continue;
4305
+ }
4306
+
4163
4307
  // Header: # ## ### ####
4164
4308
  var hMatch = trimmed.match(/^(#{1,4})\s+(.+)/);
4165
4309
  if (hMatch) {
@@ -4188,6 +4332,14 @@
4188
4332
  wrap.className = 'tableWrap';
4189
4333
  wrap.appendChild(table);
4190
4334
  wrap.appendChild(createCopyBtn(tableToText(table)));
4335
+ // Detect horizontal overflow for scroll hint
4336
+ requestAnimationFrame(function () {
4337
+ if (wrap.scrollWidth > wrap.clientWidth) wrap.classList.add('scrollable');
4338
+ });
4339
+ wrap.addEventListener('scroll', function () {
4340
+ var atEnd = wrap.scrollLeft + wrap.clientWidth >= wrap.scrollWidth - 5;
4341
+ wrap.classList.toggle('scrollable', !atEnd);
4342
+ });
4191
4343
  parent.appendChild(wrap);
4192
4344
  hasOutput = true;
4193
4345
  i = j; continue;
@@ -4451,6 +4603,18 @@
4451
4603
  continue;
4452
4604
  }
4453
4605
  }
4606
+ // Strikethrough: ~~...~~
4607
+ if (line[i] === '~' && line[i + 1] === '~') {
4608
+ var endS = line.indexOf('~~', i + 2);
4609
+ if (endS !== -1 && endS - i < 5002) {
4610
+ flushBuf();
4611
+ var del = document.createElement('del');
4612
+ del.textContent = line.slice(i + 2, endS);
4613
+ nodes.push(del);
4614
+ i = endS + 2;
4615
+ continue;
4616
+ }
4617
+ }
4454
4618
  buf += line[i];
4455
4619
  i++;
4456
4620
  }
@@ -4472,6 +4636,11 @@
4472
4636
  svg.appendChild(path);
4473
4637
  badge.appendChild(svg);
4474
4638
  badge.appendChild(document.createTextNode(' AI'));
4639
+ var ts = document.createElement('span');
4640
+ ts.className = 'aiBadgeTime';
4641
+ var now = new Date();
4642
+ ts.textContent = ' \u2022 ' + now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
4643
+ badge.appendChild(ts);
4475
4644
  return badge;
4476
4645
  }
4477
4646
 
@@ -4487,6 +4656,32 @@
4487
4656
  }
4488
4657
  msg.appendChild(createAiBadge());
4489
4658
  msg.appendChild(renderMarkdownSafe(text));
4659
+
4660
+ // Action buttons (regenerate)
4661
+ var actions = document.createElement('div');
4662
+ actions.className = 'chatMsgActions';
4663
+
4664
+ var regenBtn = document.createElement('button');
4665
+ regenBtn.className = 'chatMsgAction';
4666
+ regenBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg> Tekrar sor';
4667
+ regenBtn.onclick = function () {
4668
+ if (chatHistory.length >= 2) {
4669
+ var lastQ = chatHistory[chatHistory.length - 2];
4670
+ if (lastQ.role === 'user') {
4671
+ chatHistory.pop();
4672
+ chatHistory.pop();
4673
+ var msgs = chatMessagesEl.querySelectorAll('.chatMsg');
4674
+ if (msgs.length >= 2) {
4675
+ msgs[msgs.length - 1].remove();
4676
+ msgs[msgs.length - 2].remove();
4677
+ }
4678
+ chatInputEl.value = lastQ.text;
4679
+ sendChatMessage();
4680
+ }
4681
+ }
4682
+ };
4683
+ actions.appendChild(regenBtn);
4684
+ msg.appendChild(actions);
4490
4685
  } else if (role === 'user') {
4491
4686
  var now = new Date();
4492
4687
  var timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
@@ -4504,18 +4699,34 @@
4504
4699
  }
4505
4700
 
4506
4701
  function showChatLoading() {
4507
- const msg = document.createElement('div');
4702
+ var loadText = chatDetailMode ? 'Detayl\u0131 analiz haz\u0131rlan\u0131yor' : 'D\u00fc\u015f\u00fcn\u00fcyor';
4703
+ var msg = document.createElement('div');
4508
4704
  msg.className = 'chatMsg ai chatLoading';
4509
4705
  msg.id = 'chatLoadingMsg';
4510
- msg.innerHTML = '<span class="chatLoadingText">Düşünüyor</span><span class="chatLoadingDots"><span>.</span><span>.</span><span>.</span></span>';
4706
+ msg.innerHTML = '<span class="chatLoadingText">' + loadText + '</span><span class="chatLoadingDots"><span>.</span><span>.</span><span>.</span></span>';
4707
+ var elapsed = document.createElement('span');
4708
+ elapsed.className = 'chatLoadingElapsed';
4709
+ elapsed.style.display = 'none';
4710
+ msg.appendChild(elapsed);
4711
+ var startTime = Date.now();
4712
+ msg._timer = setInterval(function () {
4713
+ var secs = Math.floor((Date.now() - startTime) / 1000);
4714
+ if (secs >= 5) {
4715
+ elapsed.style.display = '';
4716
+ elapsed.textContent = '(' + secs + 's)';
4717
+ }
4718
+ }, 1000);
4511
4719
  chatMessagesEl.appendChild(msg);
4512
4720
  chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
4513
4721
  return msg;
4514
4722
  }
4515
4723
 
4516
4724
  function removeChatLoading() {
4517
- const el = document.getElementById('chatLoadingMsg');
4518
- if (el) el.remove();
4725
+ var el = document.getElementById('chatLoadingMsg');
4726
+ if (el) {
4727
+ if (el._timer) clearInterval(el._timer);
4728
+ el.remove();
4729
+ }
4519
4730
  }
4520
4731
 
4521
4732
  // Quota usage bar
@@ -4626,9 +4837,14 @@
4626
4837
  suggestions.appendChild(chip);
4627
4838
  });
4628
4839
 
4629
- // Trigger AI suggestions on hover/focus of the suggestions area (lazy)
4630
- suggestions.addEventListener('mouseenter', loadAiSuggestions, { once: true });
4631
- suggestions.addEventListener('focusin', loadAiSuggestions, { once: true });
4840
+ // VIP: auto-load context-aware suggestions immediately
4841
+ // Premium: lazy-load on hover/focus (avoids unnecessary API calls)
4842
+ if (chatIsVip) {
4843
+ setTimeout(loadAiSuggestions, 0);
4844
+ } else {
4845
+ suggestions.addEventListener('mouseenter', loadAiSuggestions, { once: true });
4846
+ suggestions.addEventListener('focusin', loadAiSuggestions, { once: true });
4847
+ }
4632
4848
 
4633
4849
  chatMessagesEl.appendChild(suggestions);
4634
4850
  chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
@@ -4815,6 +5031,7 @@
4815
5031
  filename: _cfg.filename,
4816
5032
  question: question,
4817
5033
  history: chatHistory,
5034
+ detailMode: chatDetailMode,
4818
5035
  }),
4819
5036
  });
4820
5037