nodebb-plugin-pdf-secure2 1.4.2 → 1.4.4

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));
@@ -331,7 +332,10 @@ Controllers.handleChat = async function (req, res) {
331
332
  return res.status(404).json({ error: 'PDF bulunamadı.', quota });
332
333
  }
333
334
  if (err.message === 'PDF too large for AI chat') {
334
- return res.status(413).json({ error: 'Bu PDF çok büyük. AI chat desteklemiyor.', quota });
335
+ const sizeMsg = tier === 'premium'
336
+ ? 'Bu PDF, Premium limiti (20MB) aşıyor. VIP üyelikle 50MB\'a kadar PDF\'lerle sohbet edebilirsiniz.'
337
+ : 'Bu PDF çok büyük. AI chat için maksimum dosya boyutu 50MB.';
338
+ return res.status(413).json({ error: sizeMsg, quota, showUpgrade: tier === 'premium' });
335
339
  }
336
340
  if (err.status === 429 || err.message.includes('rate limit') || err.message.includes('quota')) {
337
341
  return res.status(429).json({ error: 'AI servisi şu an yoğun. Lütfen birkaç saniye sonra tekrar deneyin.', quota });
@@ -345,6 +349,13 @@ Controllers.handleChat = async function (req, res) {
345
349
 
346
350
  // AI-powered question suggestions for a PDF
347
351
  const suggestionsRateLimit = new Map(); // uid -> lastRequestTime
352
+ // Periodic cleanup of stale rate limit entries (every 5 minutes)
353
+ setInterval(() => {
354
+ const cutoff = Date.now() - 60000; // entries older than 60s are stale
355
+ for (const [uid, ts] of suggestionsRateLimit.entries()) {
356
+ if (ts < cutoff) suggestionsRateLimit.delete(uid);
357
+ }
358
+ }, 5 * 60 * 1000).unref();
348
359
  Controllers.getSuggestions = async function (req, res) {
349
360
  if (!req.uid) {
350
361
  return res.status(401).json({ error: 'Authentication required' });
@@ -385,7 +396,7 @@ Controllers.getSuggestions = async function (req, res) {
385
396
  }
386
397
 
387
398
  try {
388
- const suggestions = await geminiChat.generateSuggestions(safeName);
399
+ const suggestions = await geminiChat.generateSuggestions(safeName, tier);
389
400
  const quotaUsage = await getQuotaUsage(req.uid, tier);
390
401
  return res.json({ suggestions, quota: quotaUsage });
391
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
- - Önce kısa ve net cevapla, sonra gerekirse detaylandır. Gereksiz yere uzatma
42
- - Konuyu örneklerle ve analojilerle açıkla, soyut kavramları somutlaştır
43
- - Matematiksel işlemleri adım adım çöz, her adımı gerekçesiyle açıkla
44
- - Sınav ve quiz hazırlığı için ipuçları, stratejiler ve olası soru kalıpları ver
45
- - İlişkili konulara referans ver, kavramlar arası bağlantıları kur
46
- - Gerektiğinde karşılaştırma tabloları ve kavram haritaları oluştur
47
- - Ezber teknikleri ve hatırlatıcı kısayollar öner
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
58
+ - Yanıtlarını madde, başlık ve yapısal format kullanarak düzenli sun
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
 
@@ -140,6 +151,13 @@ const cleanupTimer = setInterval(() => {
140
151
  }, 10 * 60 * 1000);
141
152
  cleanupTimer.unref();
142
153
 
154
+ // Admin-configured custom prompts (override defaults when non-empty)
155
+ let customPrompts = { premium: '', vip: '' };
156
+
157
+ GeminiChat.setCustomPrompts = function (prompts) {
158
+ customPrompts = prompts || { premium: '', vip: '' };
159
+ };
160
+
143
161
  GeminiChat.init = function (apiKey) {
144
162
  if (!apiKey) {
145
163
  ai = null;
@@ -242,7 +260,7 @@ function isSummaryRequest(question, history) {
242
260
  return SUMMARY_PATTERNS.some((p) => p.test(question));
243
261
  }
244
262
 
245
- GeminiChat.chat = async function (filename, question, history, tier) {
263
+ GeminiChat.chat = async function (filename, question, history, tier, detailMode) {
246
264
  if (!ai) {
247
265
  throw new Error('AI chat is not configured');
248
266
  }
@@ -312,12 +330,31 @@ GeminiChat.chat = async function (filename, question, history, tier) {
312
330
  ...contents,
313
331
  ];
314
332
 
333
+ // Use admin-configured prompt if set, otherwise fall back to defaults
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
+ }
344
+
345
+ // Always append security rules if not already present (admin can't bypass)
346
+ if (!systemInstruction.includes('Güvenlik kuralları')) {
347
+ systemInstruction += '\n' + SECURITY_RULES;
348
+ }
349
+
350
+ const maxOutputTokens = (tier === 'vip' && detailMode) ? config.detailedMaxOutputTokens : config.maxOutputTokens;
351
+
315
352
  const response = await ai.models.generateContent({
316
353
  model: MODEL_NAME,
317
354
  contents: fullContents,
318
355
  config: {
319
- systemInstruction: tier === 'vip' ? SYSTEM_INSTRUCTION_VIP : SYSTEM_INSTRUCTION_PREMIUM,
320
- maxOutputTokens: config.maxOutputTokens,
356
+ systemInstruction,
357
+ maxOutputTokens,
321
358
  },
322
359
  });
323
360
 
@@ -351,18 +388,20 @@ GeminiChat.chat = async function (filename, question, history, tier) {
351
388
  };
352
389
 
353
390
  // Generate AI-powered question suggestions for a PDF
354
- GeminiChat.generateSuggestions = async function (filename) {
391
+ GeminiChat.generateSuggestions = async function (filename, tier) {
355
392
  if (!ai) {
356
393
  throw new Error('AI chat is not configured');
357
394
  }
358
395
 
359
- // Check cache
360
- 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);
361
399
  if (cached && Date.now() - cached.cachedAt < SUGGESTIONS_TTL) {
362
400
  return cached.suggestions;
363
401
  }
364
402
 
365
- const pdfRef = await getOrUploadPdf(filename, 'premium');
403
+ const count = tier === 'vip' ? 3 : 5;
404
+ const pdfRef = await getOrUploadPdf(filename, tier || 'premium');
366
405
  const pdfPart = pdfRef.type === 'fileData'
367
406
  ? { fileData: { fileUri: pdfRef.fileUri, mimeType: pdfRef.mimeType } }
368
407
  : { inlineData: { mimeType: pdfRef.mimeType, data: pdfRef.data } };
@@ -374,7 +413,13 @@ GeminiChat.generateSuggestions = async function (filename) {
374
413
  role: 'user',
375
414
  parts: [
376
415
  pdfPart,
377
- { 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?"]` },
378
423
  ],
379
424
  },
380
425
  ],
@@ -397,13 +442,13 @@ GeminiChat.generateSuggestions = async function (filename) {
397
442
  // Sanitize and limit
398
443
  suggestions = suggestions
399
444
  .filter(s => typeof s === 'string' && s.length > 0)
400
- .slice(0, 5)
445
+ .slice(0, count)
401
446
  .map(s => s.slice(0, 200));
402
447
  } catch (err) {
403
448
  // Fallback: return empty, client will keep defaults
404
449
  suggestions = [];
405
450
  }
406
451
 
407
- suggestionsCache.set(filename, { suggestions, cachedAt: Date.now() });
452
+ suggestionsCache.set(cacheKey, { suggestions, cachedAt: Date.now() });
408
453
  return suggestions;
409
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/library.js CHANGED
@@ -76,6 +76,12 @@ plugin.init = async (params) => {
76
76
  // Apply admin-configured quota settings
77
77
  controllers.initQuotaSettings(pluginSettings);
78
78
 
79
+ // Apply admin-configured custom prompts
80
+ geminiChat.setCustomPrompts({
81
+ premium: pluginSettings.promptPremium || '',
82
+ vip: pluginSettings.promptVip || '',
83
+ });
84
+
79
85
  const watermarkEnabled = pluginSettings.watermarkEnabled === 'on';
80
86
 
81
87
  // Admin page route
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure2",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
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": {
@@ -456,7 +456,7 @@
456
456
  iframe.src = config.relative_path + '/plugins/pdf-secure/viewer?file=' + encodeURIComponent(filename);
457
457
  iframe.setAttribute('frameborder', '0');
458
458
  iframe.setAttribute('allowfullscreen', 'true');
459
- iframe.setAttribute('allow', 'fullscreen');
459
+ iframe.setAttribute('allow', 'fullscreen; clipboard-write');
460
460
 
461
461
  // Store resolver for postMessage callback
462
462
  currentResolver = function () {
@@ -96,6 +96,27 @@
96
96
  </div>
97
97
  </div>
98
98
 
99
+ <hr class="my-3">
100
+ <h6 class="fw-semibold mb-3" style="font-size:13px;">AI Sistem Prompt'lari</h6>
101
+
102
+ <div class="mb-3">
103
+ <label class="form-label fw-medium" for="promptPremium" style="font-size:13px;">
104
+ <span class="badge text-bg-primary me-1" style="font-size:9px;">PREMIUM</span>
105
+ Sistem Prompt
106
+ </label>
107
+ <textarea id="promptPremium" name="promptPremium" data-key="promptPremium" class="form-control" rows="6" style="font-size:12px;" placeholder="Varsayilan prompt kullanilir..."></textarea>
108
+ <div class="form-text" style="font-size:11px;">Bos birakilirsa varsayilan prompt kullanilir. Guvenlik kurallari otomatik eklenir.</div>
109
+ </div>
110
+
111
+ <div class="mb-3">
112
+ <label class="form-label fw-medium" for="promptVip" style="font-size:13px;">
113
+ <span class="badge me-1" style="font-size:9px;background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff;">VIP</span>
114
+ Sistem Prompt
115
+ </label>
116
+ <textarea id="promptVip" name="promptVip" data-key="promptVip" class="form-control" rows="8" style="font-size:12px;" placeholder="Varsayilan prompt kullanilir..."></textarea>
117
+ <div class="form-text" style="font-size:11px;">Bos birakilirsa varsayilan prompt kullanilir. Guvenlik kurallari otomatik eklenir.</div>
118
+ </div>
119
+
99
120
  <hr class="my-3">
100
121
  <h6 class="fw-semibold mb-3" style="font-size:13px;">Token Kota Ayarlari</h6>
101
122
 
@@ -7,6 +7,10 @@
7
7
  content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
8
8
  <title>PDF Viewer</title>
9
9
 
10
+ <!-- KaTeX (math rendering) -->
11
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
12
+ <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
13
+
10
14
  <!-- PDF.js -->
11
15
  <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" integrity="sha384-/1qUCSGwTur9vjf/z9lmu/eCUYbpOTgSjmpbMQZ1/CtX2v/WcAIKqRv+U1DUCG6e" crossorigin="anonymous"></script>
12
16
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf_viewer.min.css" integrity="sha384-OPLBIVpTWn5IeFhdZcMhX+XMJ4ZMKDN6ykKH+ZxgPvlpLnTW8qrpBePnYNjyeBQM" crossorigin="anonymous">
@@ -749,6 +753,110 @@
749
753
  padding: 0;
750
754
  }
751
755
 
756
+ /* Markdown tables in chat */
757
+ .chatMsg .tableWrap {
758
+ overflow-x: auto;
759
+ margin: 6px 0;
760
+ }
761
+ .chatMsg table {
762
+ width: 100%;
763
+ border-collapse: collapse;
764
+ font-size: 12px;
765
+ }
766
+ .chatMsg th, .chatMsg td {
767
+ border: 1px solid rgba(255,255,255,0.15);
768
+ padding: 4px 8px;
769
+ text-align: left;
770
+ }
771
+ .chatMsg th {
772
+ background: rgba(255,255,255,0.08);
773
+ font-weight: 600;
774
+ }
775
+ .chatMsg tbody tr:nth-child(even) {
776
+ background: rgba(255,255,255,0.03);
777
+ }
778
+
779
+ /* Markdown lists in chat */
780
+ .chatMsg ul, .chatMsg ol {
781
+ margin: 4px 0;
782
+ padding-left: 20px;
783
+ }
784
+ .chatMsg li {
785
+ margin: 2px 0;
786
+ }
787
+
788
+ /* Markdown headers in chat */
789
+ .chatMsg h3, .chatMsg h4, .chatMsg h5 {
790
+ margin: 8px 0 4px;
791
+ font-weight: 600;
792
+ line-height: 1.3;
793
+ }
794
+ .chatMsg h3 { font-size: 15px; }
795
+ .chatMsg h4 { font-size: 14px; }
796
+ .chatMsg h5 { font-size: 13px; }
797
+
798
+ /* Markdown HR in chat */
799
+ .chatMsg hr {
800
+ border: none;
801
+ border-top: 1px solid rgba(255,255,255,0.15);
802
+ margin: 8px 0;
803
+ }
804
+
805
+ /* Copy button for code blocks and tables */
806
+ .chatMsg pre, .chatMsg .tableWrap {
807
+ position: relative;
808
+ }
809
+ .chatCopyBtn {
810
+ position: absolute;
811
+ top: 4px;
812
+ right: 4px;
813
+ background: rgba(255,255,255,0.1);
814
+ border: 1px solid rgba(255,255,255,0.15);
815
+ border-radius: 4px;
816
+ color: var(--text-secondary);
817
+ font-size: 10px;
818
+ padding: 2px 6px;
819
+ cursor: pointer;
820
+ opacity: 0;
821
+ transition: opacity 0.15s;
822
+ z-index: 1;
823
+ display: flex;
824
+ align-items: center;
825
+ gap: 3px;
826
+ }
827
+ .chatMsg pre:hover .chatCopyBtn,
828
+ .chatMsg .tableWrap:hover .chatCopyBtn {
829
+ opacity: 1;
830
+ }
831
+ .chatCopyBtn:hover {
832
+ background: rgba(255,255,255,0.2);
833
+ color: var(--text-primary);
834
+ }
835
+ .chatCopyBtn.copied {
836
+ color: #4ade80;
837
+ opacity: 1;
838
+ }
839
+
840
+ /* LaTeX block math */
841
+ .chatMsg .katex-block {
842
+ overflow-x: auto;
843
+ margin: 6px 0;
844
+ padding: 4px 0;
845
+ }
846
+ .chatMsg .katex-inline {
847
+ display: inline;
848
+ }
849
+
850
+ /* VIP tip chips clickable */
851
+ .vipTipsBanner .vipTip {
852
+ cursor: pointer;
853
+ transition: background 0.15s, color 0.15s;
854
+ }
855
+ .vipTipsBanner .vipTip:hover {
856
+ background: rgba(255,215,0,0.22);
857
+ color: #ffd700;
858
+ }
859
+
752
860
  /* AI badge on AI messages */
753
861
  .chatMsg.ai .aiBadge {
754
862
  display: flex;
@@ -862,6 +970,40 @@
862
970
  body[data-tier="vip"] .chatSuggestionChip:hover { border-color: #ffd700; color: #ffd700; background: rgba(255, 215, 0, 0.12); }
863
971
  body[data-tier="vip"] .chatSummaryBtn:hover { color: #ffd700; border-color: #ffd700; }
864
972
 
973
+ /* VIP feature tips banner */
974
+ .vipTipsBanner {
975
+ display: none;
976
+ padding: 6px 12px;
977
+ background: linear-gradient(135deg, rgba(255,215,0,0.08), rgba(255,170,0,0.05));
978
+ border-bottom: 1px solid rgba(255,215,0,0.15);
979
+ font-size: 11px;
980
+ color: var(--text-secondary);
981
+ line-height: 1.4;
982
+ }
983
+ .vipTipsBanner .vipTipsLabel {
984
+ color: #ffd700;
985
+ font-weight: 600;
986
+ font-size: 10px;
987
+ letter-spacing: 0.3px;
988
+ margin-bottom: 2px;
989
+ }
990
+ .vipTipsBanner .vipTipsItems {
991
+ display: flex;
992
+ flex-wrap: wrap;
993
+ gap: 4px;
994
+ margin-top: 4px;
995
+ }
996
+ .vipTipsBanner .vipTip {
997
+ background: rgba(255,215,0,0.1);
998
+ border: 1px solid rgba(255,215,0,0.15);
999
+ border-radius: 10px;
1000
+ padding: 2px 8px;
1001
+ font-size: 10px;
1002
+ color: rgba(255,215,0,0.8);
1003
+ white-space: nowrap;
1004
+ }
1005
+ body[data-tier="vip"] .vipTipsBanner { display: block; }
1006
+
865
1007
  /* Suggestion chips */
866
1008
  .chatSuggestions {
867
1009
  display: flex;
@@ -937,6 +1079,108 @@
937
1079
  margin-right: 2px;
938
1080
  }
939
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
+
940
1184
  /* Character counter */
941
1185
  .chatCharCount {
942
1186
  font-size: 10px;
@@ -2868,7 +3112,25 @@
2868
3112
  </button>
2869
3113
  <button class="closeBtn" id="closeChatSidebar">&times;</button>
2870
3114
  </div>
3115
+ <div class="vipTipsBanner" id="vipTipsBanner">
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>
3124
+ <div class="vipTipsItems">
3125
+ <span class="vipTip">detayli aciklama</span>
3126
+ <span class="vipTip">ornek ver</span>
3127
+ <span class="vipTip">sinav sorusu</span>
3128
+ <span class="vipTip">karsilastirma tablosu</span>
3129
+ <span class="vipTip">ezber teknigi</span>
3130
+ </div>
3131
+ </div>
2871
3132
  <div id="chatMessages"></div>
3133
+ <button id="chatScrollDown" class="chatScrollDown">&#x2193;</button>
2872
3134
  <div class="chatCharCount" id="chatCharCount"></div>
2873
3135
  <div class="chatQuotaBar" id="chatQuotaBar">
2874
3136
  <div class="chatQuotaTrack"><div class="chatQuotaFill" id="chatQuotaFill"></div></div>
@@ -3824,6 +4086,17 @@
3824
4086
  document.body.setAttribute('data-tier', 'vip');
3825
4087
  }
3826
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
+
3827
4100
  // Non-premium: hide input area entirely and show PRO badge
3828
4101
  if (!chatIsPremium) {
3829
4102
  document.getElementById('chatInputArea').style.display = 'none';
@@ -3858,6 +4131,18 @@
3858
4131
  };
3859
4132
  }
3860
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
+
3861
4146
  // MutationObserver: prevent dangerous elements in chat (DevTools injection defense)
3862
4147
  var dangerousTags = ['A', 'SCRIPT', 'IFRAME', 'FORM', 'OBJECT', 'EMBED', 'LINK'];
3863
4148
  new MutationObserver(function (mutations) {
@@ -3901,6 +4186,25 @@
3901
4186
 
3902
4187
  chatBtnEl.onclick = toggleChat;
3903
4188
 
4189
+ // VIP tip chips — click to populate chat input
4190
+ var vipTipChips = document.querySelectorAll('.vipTip');
4191
+ var vipTipPrompts = {
4192
+ 'detayli aciklama': 'Bu konuyu detayli aciklar misin?',
4193
+ 'ornek ver': 'Bununla ilgili somut bir ornek verir misin?',
4194
+ 'sinav sorusu': 'Bu konudan cikmis veya cikabilecek sinav sorulari neler?',
4195
+ 'karsilastirma tablosu': 'Bu konudaki kavramlari karsilastirma tablosu ile goster',
4196
+ 'ezber teknigi': 'Bu konuyu kolayca ezberlemek icin teknik onerir misin?',
4197
+ };
4198
+ vipTipChips.forEach(function (chip) {
4199
+ chip.onclick = function () {
4200
+ var prompt = vipTipPrompts[chip.textContent.trim()];
4201
+ if (prompt && chatInputEl) {
4202
+ chatInputEl.value = prompt;
4203
+ sendChatMessage();
4204
+ }
4205
+ };
4206
+ });
4207
+
3904
4208
  closeChatBtn.onclick = () => {
3905
4209
  chatSidebarEl.classList.remove('open');
3906
4210
  chatBtnEl.classList.remove('active');
@@ -3917,12 +4221,19 @@
3917
4221
 
3918
4222
  var frag = document.createDocumentFragment();
3919
4223
 
3920
- // Split into code blocks and normal text
3921
- var parts = text.split(/(```[\s\S]*?```)/g);
4224
+ // Split into code blocks, LaTeX blocks ($$...$$), and normal text
4225
+ var parts = text.split(/(```[\s\S]*?```|\$\$[\s\S]*?\$\$)/g);
3922
4226
  for (var pi = 0; pi < parts.length; pi++) {
3923
4227
  var part = parts[pi];
3924
4228
  if (!part) continue;
3925
4229
 
4230
+ // LaTeX block: $$...$$
4231
+ if (part.startsWith('$$') && part.endsWith('$$') && part.length > 4) {
4232
+ var latex = part.slice(2, -2).trim();
4233
+ frag.appendChild(renderMath(latex, true));
4234
+ continue;
4235
+ }
4236
+
3926
4237
  // Code block
3927
4238
  if (part.startsWith('```') && part.endsWith('```')) {
3928
4239
  var codeContent = part.slice(3, -3);
@@ -3935,31 +4246,267 @@
3935
4246
  var code = document.createElement('code');
3936
4247
  code.textContent = codeContent.trim();
3937
4248
  pre.appendChild(code);
4249
+ pre.appendChild(createCopyBtn(codeContent.trim()));
3938
4250
  frag.appendChild(pre);
3939
4251
  continue;
3940
4252
  }
3941
4253
 
3942
- // Normal text — process inline formatting
3943
- renderInlineMarkdown(part, frag);
4254
+ // Normal text — process block and inline formatting
4255
+ renderBlocks(part, frag);
3944
4256
  }
3945
4257
  return frag;
3946
4258
  }
3947
4259
 
3948
- function renderInlineMarkdown(text, parent) {
3949
- // Split by lines first
4260
+ // Block-level markdown: tables, lists, headers, HR
4261
+ // Falls back to inline formatting for regular text
4262
+ function renderBlocks(text, parent) {
3950
4263
  var lines = text.split('\n');
3951
- for (var li = 0; li < lines.length; li++) {
3952
- if (li > 0) parent.appendChild(document.createElement('br'));
3953
- var line = lines[li];
3954
- if (!line) continue;
3955
-
3956
- // Tokenize: inline code, bold, italic, page citations
3957
- // Process left-to-right with a simple state machine
3958
- var tokens = tokenizeInline(line);
3959
- for (var ti = 0; ti < tokens.length; ti++) {
3960
- parent.appendChild(tokens[ti]);
4264
+ var i = 0;
4265
+ var paraBuf = [];
4266
+ var hasOutput = false; // track if we've rendered anything yet
4267
+
4268
+ function flushPara() {
4269
+ if (paraBuf.length === 0) return;
4270
+ if (hasOutput) parent.appendChild(document.createElement('br'));
4271
+ for (var pi = 0; pi < paraBuf.length; pi++) {
4272
+ if (pi > 0) parent.appendChild(document.createElement('br'));
4273
+ var tokens = tokenizeInline(paraBuf[pi]);
4274
+ for (var t = 0; t < tokens.length; t++) {
4275
+ parent.appendChild(tokens[t]);
4276
+ }
4277
+ }
4278
+ paraBuf = [];
4279
+ hasOutput = true;
4280
+ }
4281
+
4282
+ while (i < lines.length) {
4283
+ var trimmed = lines[i].trim();
4284
+
4285
+ // Empty line — flush paragraph buffer
4286
+ if (!trimmed) { flushPara(); i++; continue; }
4287
+
4288
+ // Horizontal rule: --- or *** or ___
4289
+ if (/^[-*_]{3,}\s*$/.test(trimmed) && !/\S/.test(trimmed.replace(/[-*_]/g, ''))) {
4290
+ flushPara();
4291
+ parent.appendChild(document.createElement('hr'));
4292
+ hasOutput = true;
4293
+ i++; continue;
4294
+ }
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
+
4307
+ // Header: # ## ### ####
4308
+ var hMatch = trimmed.match(/^(#{1,4})\s+(.+)/);
4309
+ if (hMatch) {
4310
+ flushPara();
4311
+ var hEl = document.createElement('h' + Math.min(hMatch[1].length + 2, 5));
4312
+ var hTokens = tokenizeInline(hMatch[2]);
4313
+ for (var t = 0; t < hTokens.length; t++) hEl.appendChild(hTokens[t]);
4314
+ parent.appendChild(hEl);
4315
+ hasOutput = true;
4316
+ i++; continue;
4317
+ }
4318
+
4319
+ // Table: lines containing | with a separator row
4320
+ if (trimmed.includes('|') && i + 1 < lines.length) {
4321
+ var tLines = [];
4322
+ var j = i;
4323
+ while (j < lines.length && lines[j].trim().includes('|')) {
4324
+ tLines.push(lines[j].trim());
4325
+ j++;
4326
+ }
4327
+ if (tLines.length >= 2 && /^\|?[\s:|-]+\|?$/.test(tLines[1]) && tLines[1].includes('-')) {
4328
+ flushPara();
4329
+ var table = renderTable(tLines);
4330
+ if (table) {
4331
+ var wrap = document.createElement('div');
4332
+ wrap.className = 'tableWrap';
4333
+ wrap.appendChild(table);
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
+ });
4343
+ parent.appendChild(wrap);
4344
+ hasOutput = true;
4345
+ i = j; continue;
4346
+ }
4347
+ }
4348
+ }
4349
+
4350
+ // Unordered list: - item or * item (but not ---)
4351
+ if (/^[-*]\s+/.test(trimmed)) {
4352
+ flushPara();
4353
+ var ul = document.createElement('ul');
4354
+ while (i < lines.length) {
4355
+ var lt = lines[i].trim();
4356
+ var lm = lt.match(/^[-*]\s+(.*)/);
4357
+ if (lm) {
4358
+ var li2 = document.createElement('li');
4359
+ var liTokens = tokenizeInline(lm[1]);
4360
+ for (var t = 0; t < liTokens.length; t++) li2.appendChild(liTokens[t]);
4361
+ ul.appendChild(li2);
4362
+ i++;
4363
+ } else if (!lt) {
4364
+ i++;
4365
+ if (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) continue;
4366
+ break;
4367
+ } else { break; }
4368
+ }
4369
+ parent.appendChild(ul);
4370
+ hasOutput = true;
4371
+ continue;
4372
+ }
4373
+
4374
+ // Ordered list: 1. item or 1) item
4375
+ if (/^\d+[.)]\s+/.test(trimmed)) {
4376
+ flushPara();
4377
+ var ol = document.createElement('ol');
4378
+ while (i < lines.length) {
4379
+ var lt = lines[i].trim();
4380
+ var om = lt.match(/^\d+[.)]\s+(.*)/);
4381
+ if (om) {
4382
+ var li3 = document.createElement('li');
4383
+ var oTokens = tokenizeInline(om[1]);
4384
+ for (var t = 0; t < oTokens.length; t++) li3.appendChild(oTokens[t]);
4385
+ ol.appendChild(li3);
4386
+ i++;
4387
+ } else if (!lt) {
4388
+ i++;
4389
+ if (i < lines.length && /^\d+[.)]\s+/.test(lines[i].trim())) continue;
4390
+ break;
4391
+ } else { break; }
4392
+ }
4393
+ parent.appendChild(ol);
4394
+ hasOutput = true;
4395
+ continue;
3961
4396
  }
4397
+
4398
+ // Regular text line — buffer for paragraph
4399
+ paraBuf.push(trimmed);
4400
+ i++;
4401
+ }
4402
+ flushPara();
4403
+ }
4404
+
4405
+ function renderTable(tLines) {
4406
+ var headerCells = parseTableRow(tLines[0]);
4407
+ if (!headerCells || headerCells.length === 0) return null;
4408
+
4409
+ // Parse alignments from separator row
4410
+ var sepCells = parseTableRow(tLines[1]);
4411
+ var aligns = sepCells ? sepCells.map(function (c) {
4412
+ c = c.trim();
4413
+ if (c.charAt(0) === ':' && c.charAt(c.length - 1) === ':') return 'center';
4414
+ if (c.charAt(c.length - 1) === ':') return 'right';
4415
+ return '';
4416
+ }) : [];
4417
+
4418
+ var table = document.createElement('table');
4419
+ var thead = document.createElement('thead');
4420
+ var hRow = document.createElement('tr');
4421
+ for (var c = 0; c < headerCells.length; c++) {
4422
+ var th = document.createElement('th');
4423
+ if (aligns[c]) th.style.textAlign = aligns[c];
4424
+ var tokens = tokenizeInline(headerCells[c].trim());
4425
+ for (var t = 0; t < tokens.length; t++) th.appendChild(tokens[t]);
4426
+ hRow.appendChild(th);
4427
+ }
4428
+ thead.appendChild(hRow);
4429
+ table.appendChild(thead);
4430
+
4431
+ var tbody = document.createElement('tbody');
4432
+ for (var r = 2; r < tLines.length; r++) {
4433
+ var cells = parseTableRow(tLines[r]);
4434
+ if (!cells) continue;
4435
+ var tr = document.createElement('tr');
4436
+ for (var c = 0; c < cells.length; c++) {
4437
+ var td = document.createElement('td');
4438
+ if (aligns[c]) td.style.textAlign = aligns[c];
4439
+ var tokens = tokenizeInline((cells[c] || '').trim());
4440
+ for (var t = 0; t < tokens.length; t++) td.appendChild(tokens[t]);
4441
+ tr.appendChild(td);
4442
+ }
4443
+ tbody.appendChild(tr);
4444
+ }
4445
+ table.appendChild(tbody);
4446
+ return table;
4447
+ }
4448
+
4449
+ function parseTableRow(line) {
4450
+ line = line.trim();
4451
+ if (line.charAt(0) === '|') line = line.slice(1);
4452
+ if (line.charAt(line.length - 1) === '|') line = line.slice(0, -1);
4453
+ if (!line) return null;
4454
+ return line.split('|');
4455
+ }
4456
+
4457
+ // Copy button helper (used for code blocks and tables)
4458
+ function createCopyBtn(textToCopy) {
4459
+ var btn = document.createElement('button');
4460
+ btn.className = 'chatCopyBtn';
4461
+ btn.textContent = 'Kopyala';
4462
+ btn.onclick = function (e) {
4463
+ e.stopPropagation();
4464
+ navigator.clipboard.writeText(textToCopy).then(function () {
4465
+ btn.textContent = 'Kopyalandi!';
4466
+ btn.classList.add('copied');
4467
+ setTimeout(function () {
4468
+ btn.textContent = 'Kopyala';
4469
+ btn.classList.remove('copied');
4470
+ }, 1500);
4471
+ }).catch(function () {
4472
+ btn.textContent = 'Hata!';
4473
+ setTimeout(function () { btn.textContent = 'Kopyala'; }, 1500);
4474
+ });
4475
+ };
4476
+ return btn;
4477
+ }
4478
+
4479
+ // Convert table element to tab-separated text for clipboard
4480
+ function tableToText(table) {
4481
+ var lines = [];
4482
+ var rows = table.querySelectorAll('tr');
4483
+ for (var r = 0; r < rows.length; r++) {
4484
+ var cells = rows[r].querySelectorAll('th, td');
4485
+ var line = [];
4486
+ for (var c = 0; c < cells.length; c++) {
4487
+ line.push(cells[c].textContent.trim());
4488
+ }
4489
+ lines.push(line.join('\t'));
4490
+ }
4491
+ return lines.join('\n');
4492
+ }
4493
+
4494
+ // Render LaTeX math expression (inline or block)
4495
+ function renderMath(latex, displayMode) {
4496
+ if (typeof katex === 'undefined') {
4497
+ // KaTeX not loaded, show raw LaTeX
4498
+ var fallback = document.createElement('code');
4499
+ fallback.textContent = (displayMode ? '$$' : '$') + latex + (displayMode ? '$$' : '$');
4500
+ return fallback;
3962
4501
  }
4502
+ var container = document.createElement('span');
4503
+ container.className = displayMode ? 'katex-block' : 'katex-inline';
4504
+ try {
4505
+ katex.render(latex, container, { displayMode: displayMode, throwOnError: false });
4506
+ } catch (e) {
4507
+ container.textContent = latex;
4508
+ }
4509
+ return container;
3963
4510
  }
3964
4511
 
3965
4512
  function tokenizeInline(line) {
@@ -4000,6 +4547,26 @@
4000
4547
  }
4001
4548
 
4002
4549
  while (i < line.length) {
4550
+ // Block math: $$...$$
4551
+ if (line[i] === '$' && line[i + 1] === '$') {
4552
+ var endM = line.indexOf('$$', i + 2);
4553
+ if (endM !== -1) {
4554
+ flushBuf();
4555
+ nodes.push(renderMath(line.slice(i + 2, endM), true));
4556
+ i = endM + 2;
4557
+ continue;
4558
+ }
4559
+ }
4560
+ // Inline math: $...$ (but not $$)
4561
+ if (line[i] === '$' && line[i + 1] !== '$') {
4562
+ var endM2 = line.indexOf('$', i + 1);
4563
+ if (endM2 !== -1 && endM2 > i + 1) {
4564
+ flushBuf();
4565
+ nodes.push(renderMath(line.slice(i + 1, endM2), false));
4566
+ i = endM2 + 1;
4567
+ continue;
4568
+ }
4569
+ }
4003
4570
  // Inline code: `...`
4004
4571
  if (line[i] === '`') {
4005
4572
  var end = line.indexOf('`', i + 1);
@@ -4036,6 +4603,18 @@
4036
4603
  continue;
4037
4604
  }
4038
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
+ }
4039
4618
  buf += line[i];
4040
4619
  i++;
4041
4620
  }
@@ -4057,6 +4636,11 @@
4057
4636
  svg.appendChild(path);
4058
4637
  badge.appendChild(svg);
4059
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);
4060
4644
  return badge;
4061
4645
  }
4062
4646
 
@@ -4072,6 +4656,32 @@
4072
4656
  }
4073
4657
  msg.appendChild(createAiBadge());
4074
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);
4075
4685
  } else if (role === 'user') {
4076
4686
  var now = new Date();
4077
4687
  var timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
@@ -4089,18 +4699,34 @@
4089
4699
  }
4090
4700
 
4091
4701
  function showChatLoading() {
4092
- 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');
4093
4704
  msg.className = 'chatMsg ai chatLoading';
4094
4705
  msg.id = 'chatLoadingMsg';
4095
- 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);
4096
4719
  chatMessagesEl.appendChild(msg);
4097
4720
  chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
4098
4721
  return msg;
4099
4722
  }
4100
4723
 
4101
4724
  function removeChatLoading() {
4102
- const el = document.getElementById('chatLoadingMsg');
4103
- if (el) el.remove();
4725
+ var el = document.getElementById('chatLoadingMsg');
4726
+ if (el) {
4727
+ if (el._timer) clearInterval(el._timer);
4728
+ el.remove();
4729
+ }
4104
4730
  }
4105
4731
 
4106
4732
  // Quota usage bar
@@ -4211,9 +4837,14 @@
4211
4837
  suggestions.appendChild(chip);
4212
4838
  });
4213
4839
 
4214
- // Trigger AI suggestions on hover/focus of the suggestions area (lazy)
4215
- suggestions.addEventListener('mouseenter', loadAiSuggestions, { once: true });
4216
- 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
+ }
4217
4848
 
4218
4849
  chatMessagesEl.appendChild(suggestions);
4219
4850
  chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
@@ -4400,6 +5031,7 @@
4400
5031
  filename: _cfg.filename,
4401
5032
  question: question,
4402
5033
  history: chatHistory,
5034
+ detailMode: chatDetailMode,
4403
5035
  }),
4404
5036
  });
4405
5037
 
@@ -4424,7 +5056,16 @@
4424
5056
  chatHistory = chatHistory.slice(-50);
4425
5057
  }
4426
5058
  } else {
4427
- addChatMessage('error', data.error || 'Bir hata oluştu. Tekrar deneyin.');
5059
+ var errorMsg = addChatMessage('error', data.error || 'Bir hata oluştu. Tekrar deneyin.');
5060
+ // Show upgrade button for file size limit errors (Premium -> VIP)
5061
+ if (data.showUpgrade && errorMsg) {
5062
+ var uid = (_cfg && _cfg.uid) || 0;
5063
+ var upgradeBtn = document.createElement('button');
5064
+ upgradeBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:linear-gradient(135deg,#ffd700,#ffaa00);color:#1a1a1a;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;';
5065
+ upgradeBtn.textContent = 'VIP\'e Y\u00fckselt';
5066
+ upgradeBtn.addEventListener('click', function () { window.open('https://forum.ieu.app/pay/checkout?uid=' + uid, '_blank'); });
5067
+ errorMsg.appendChild(upgradeBtn);
5068
+ }
4428
5069
  }
4429
5070
  } catch (err) {
4430
5071
  removeChatLoading();