nodebb-plugin-pdf-secure2 1.4.3 → 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.
- package/.claude/settings.local.json +11 -1
- package/lib/controllers.js +4 -3
- package/lib/gemini-chat.js +47 -19
- package/lib/nonce-store.js +4 -4
- package/lib/pdf-handler.js +0 -1
- package/lib/topic-access.js +96 -0
- package/package.json +1 -1
- package/plugin.json +4 -0
- package/static/viewer.html +226 -9
|
@@ -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
|
}
|
package/lib/controllers.js
CHANGED
|
@@ -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) {
|
package/lib/gemini-chat.js
CHANGED
|
@@ -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
|
-
-
|
|
42
|
-
- Sadece cevabı değil
|
|
43
|
-
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
//
|
|
377
|
-
const
|
|
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
|
|
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:
|
|
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,
|
|
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(
|
|
452
|
+
suggestionsCache.set(cacheKey, { suggestions, cachedAt: Date.now() });
|
|
425
453
|
return suggestions;
|
|
426
454
|
};
|
package/lib/nonce-store.js
CHANGED
|
@@ -53,18 +53,18 @@ NonceStore.validate = function (nonce, uid) {
|
|
|
53
53
|
return null;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
//
|
|
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
|
};
|
package/lib/pdf-handler.js
CHANGED
|
@@ -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
package/plugin.json
CHANGED
package/static/viewer.html
CHANGED
|
@@ -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">×</button>
|
|
3012
3114
|
</div>
|
|
3013
3115
|
<div class="vipTipsBanner" id="vipTipsBanner">
|
|
3014
|
-
<div class="
|
|
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">↓</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
|
-
|
|
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
|
-
|
|
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">
|
|
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
|
-
|
|
4518
|
-
if (el)
|
|
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
|
-
//
|
|
4630
|
-
|
|
4631
|
-
|
|
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
|
|