nodebb-plugin-pdf-secure2 1.3.7 → 1.3.9
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/lib/controllers.js +154 -6
- package/lib/gemini-chat.js +70 -10
- package/library.js +4 -0
- package/package.json +1 -1
- package/static/templates/admin/plugins/pdf-secure.tpl +41 -6
- package/static/viewer.html +244 -25
- package/test/image.png +0 -0
- package/static/image.png +0 -0
package/lib/controllers.js
CHANGED
|
@@ -10,12 +10,37 @@ const db = require.main.require('./src/database');
|
|
|
10
10
|
|
|
11
11
|
const Controllers = module.exports;
|
|
12
12
|
|
|
13
|
-
// Rate limiting configuration (DB-backed sliding window)
|
|
13
|
+
// Rate limiting configuration (DB-backed sliding window — spam protection, not shown in UI)
|
|
14
14
|
const RATE_LIMITS = {
|
|
15
15
|
vip: { max: 50, window: 60 * 1000 },
|
|
16
16
|
premium: { max: 25, window: 60 * 1000 },
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// 4-hour rolling window token quota (shown in UI quota bar as percentage)
|
|
20
|
+
// Tracks output tokens (candidatesTokenCount) only — fair regardless of PDF size
|
|
21
|
+
// Defaults — overridden by admin settings via initQuotaSettings()
|
|
22
|
+
const QUOTAS = {
|
|
23
|
+
vip: { max: 750000, window: 4 * 60 * 60 * 1000 }, // 750K output tokens / 4h
|
|
24
|
+
premium: { max: 200000, window: 4 * 60 * 60 * 1000 }, // 200K output tokens / 4h
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Apply admin-configured quota settings (called from library.js on init)
|
|
28
|
+
Controllers.initQuotaSettings = function (settings) {
|
|
29
|
+
if (!settings) return;
|
|
30
|
+
const premiumTokens = parseInt(settings.quotaPremiumTokens, 10);
|
|
31
|
+
const vipTokens = parseInt(settings.quotaVipTokens, 10);
|
|
32
|
+
const windowHours = parseInt(settings.quotaWindowHours, 10);
|
|
33
|
+
|
|
34
|
+
if (premiumTokens >= 10000 && premiumTokens <= 10000000) QUOTAS.premium.max = premiumTokens;
|
|
35
|
+
if (vipTokens >= 10000 && vipTokens <= 10000000) QUOTAS.vip.max = vipTokens;
|
|
36
|
+
if (windowHours >= 1 && windowHours <= 24) {
|
|
37
|
+
const windowMs = windowHours * 60 * 60 * 1000;
|
|
38
|
+
QUOTAS.premium.window = windowMs;
|
|
39
|
+
QUOTAS.vip.window = windowMs;
|
|
40
|
+
}
|
|
41
|
+
console.log('[PDF-Secure] Quota settings: Premium', QUOTAS.premium.max, 'tokens/', QUOTAS.premium.window / 3600000, 'h, VIP', QUOTAS.vip.max, 'tokens/', QUOTAS.vip.window / 3600000, 'h');
|
|
42
|
+
};
|
|
43
|
+
|
|
19
44
|
// DB-backed sliding window rate limiter
|
|
20
45
|
// Returns { allowed, used, max } for quota visibility
|
|
21
46
|
async function checkRateLimit(uid, tier) {
|
|
@@ -41,6 +66,104 @@ async function checkRateLimit(uid, tier) {
|
|
|
41
66
|
}
|
|
42
67
|
}
|
|
43
68
|
|
|
69
|
+
// Helper: parse token count from sorted set members and calculate totals
|
|
70
|
+
// Member format: "${timestamp}:${random}:${tokensUsed}"
|
|
71
|
+
function sumTokensFromMembers(members) {
|
|
72
|
+
let total = 0;
|
|
73
|
+
for (const m of members) {
|
|
74
|
+
const parts = (typeof m === 'string' ? m : m.value || '').split(':');
|
|
75
|
+
total += parseInt(parts[2], 10) || 0;
|
|
76
|
+
}
|
|
77
|
+
return total;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Token-based 4-hour sliding window quota (DB-backed sorted set)
|
|
81
|
+
// Read-only check — does NOT add entries (call recordQuotaUsage after AI response)
|
|
82
|
+
// Returns { allowed, used, max, resetsIn }
|
|
83
|
+
async function checkQuota(uid, tier) {
|
|
84
|
+
const cfg = QUOTAS[tier] || QUOTAS.premium;
|
|
85
|
+
const key = `pdf-secure:quota:${uid}`;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const windowStart = now - cfg.window;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Remove expired entries outside the 4-hour window
|
|
91
|
+
await db.sortedSetRemoveRangeByScore(key, '-inf', windowStart);
|
|
92
|
+
// Get all members to sum their token values
|
|
93
|
+
const members = await db.getSortedSetRange(key, 0, -1);
|
|
94
|
+
const totalTokens = sumTokensFromMembers(members);
|
|
95
|
+
|
|
96
|
+
// Calculate resetsIn from oldest entry
|
|
97
|
+
let resetsIn = 0;
|
|
98
|
+
if (members.length > 0) {
|
|
99
|
+
const oldest = await db.getSortedSetRangeWithScores(key, 0, 0);
|
|
100
|
+
if (oldest && oldest.length > 0) {
|
|
101
|
+
resetsIn = Math.max(0, (oldest[0].score + cfg.window) - now);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (totalTokens >= cfg.max) {
|
|
106
|
+
return { allowed: false, used: totalTokens, max: cfg.max, resetsIn };
|
|
107
|
+
}
|
|
108
|
+
return { allowed: true, used: totalTokens, max: cfg.max, resetsIn };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { allowed: true, used: 0, max: cfg.max, resetsIn: 0 };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Record token usage after a successful AI response
|
|
115
|
+
// Returns updated { used, max, resetsIn }
|
|
116
|
+
async function recordQuotaUsage(uid, tier, tokensUsed) {
|
|
117
|
+
const cfg = QUOTAS[tier] || QUOTAS.premium;
|
|
118
|
+
const key = `pdf-secure:quota:${uid}`;
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Add entry with token count encoded in member string
|
|
123
|
+
await db.sortedSetAdd(key, now, `${now}:${crypto.randomBytes(4).toString('hex')}:${tokensUsed}`);
|
|
124
|
+
|
|
125
|
+
// Re-read total for accurate response
|
|
126
|
+
const members = await db.getSortedSetRange(key, 0, -1);
|
|
127
|
+
const totalTokens = sumTokensFromMembers(members);
|
|
128
|
+
|
|
129
|
+
let resetsIn = 0;
|
|
130
|
+
if (members.length > 0) {
|
|
131
|
+
const oldest = await db.getSortedSetRangeWithScores(key, 0, 0);
|
|
132
|
+
if (oldest && oldest.length > 0) {
|
|
133
|
+
resetsIn = Math.max(0, (oldest[0].score + cfg.window) - now);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { used: totalTokens, max: cfg.max, resetsIn };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { used: 0, max: cfg.max, resetsIn: 0 };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Read-only quota usage (for initial UI display, does not increment)
|
|
144
|
+
async function getQuotaUsage(uid, tier) {
|
|
145
|
+
const cfg = QUOTAS[tier] || QUOTAS.premium;
|
|
146
|
+
const key = `pdf-secure:quota:${uid}`;
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const windowStart = now - cfg.window;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await db.sortedSetRemoveRangeByScore(key, '-inf', windowStart);
|
|
152
|
+
const members = await db.getSortedSetRange(key, 0, -1);
|
|
153
|
+
const totalTokens = sumTokensFromMembers(members);
|
|
154
|
+
let resetsIn = 0;
|
|
155
|
+
if (members.length > 0) {
|
|
156
|
+
const oldest = await db.getSortedSetRangeWithScores(key, 0, 0);
|
|
157
|
+
if (oldest && oldest.length > 0) {
|
|
158
|
+
resetsIn = Math.max(0, (oldest[0].score + cfg.window) - now);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { used: totalTokens, max: cfg.max, resetsIn };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return { used: 0, max: cfg.max, resetsIn: 0 };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
44
167
|
// AES-256-GCM encryption - replaces weak XOR obfuscation
|
|
45
168
|
function aesGcmEncrypt(buffer, key, iv) {
|
|
46
169
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
@@ -132,10 +255,21 @@ Controllers.handleChat = async function (req, res) {
|
|
|
132
255
|
const isVip = isVipMember || isAdmin;
|
|
133
256
|
const tier = isVip ? 'vip' : 'premium';
|
|
134
257
|
|
|
135
|
-
//
|
|
258
|
+
// 4-hour rolling window quota check (visible to user in quota bar)
|
|
259
|
+
const quotaResult = await checkQuota(req.uid, tier);
|
|
260
|
+
if (!quotaResult.allowed) {
|
|
261
|
+
return res.status(429).json({
|
|
262
|
+
error: 'Mesaj kotanız doldu.',
|
|
263
|
+
quotaExhausted: true,
|
|
264
|
+
quota: { used: quotaResult.used, max: quotaResult.max, resetsIn: quotaResult.resetsIn },
|
|
265
|
+
tier,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Spam rate limiting (DB-backed 60s sliding window, not shown in UI)
|
|
136
270
|
const rateResult = await checkRateLimit(req.uid, tier);
|
|
137
271
|
if (!rateResult.allowed) {
|
|
138
|
-
return res.status(429).json({ error: 'Çok hızlı mesaj gönderiyorsunuz. Lütfen biraz bekleyin.', quota: { used:
|
|
272
|
+
return res.status(429).json({ error: 'Çok hızlı mesaj gönderiyorsunuz. Lütfen biraz bekleyin.', quota: { used: quotaResult.used, max: quotaResult.max, resetsIn: quotaResult.resetsIn } });
|
|
139
273
|
}
|
|
140
274
|
|
|
141
275
|
// Body validation
|
|
@@ -158,6 +292,7 @@ Controllers.handleChat = async function (req, res) {
|
|
|
158
292
|
return res.status(400).json({ error: 'Invalid history (max 50 entries)' });
|
|
159
293
|
}
|
|
160
294
|
if (history) {
|
|
295
|
+
let totalHistorySize = 0;
|
|
161
296
|
for (const entry of history) {
|
|
162
297
|
if (!entry || typeof entry !== 'object') {
|
|
163
298
|
return res.status(400).json({ error: 'Invalid history entry' });
|
|
@@ -168,6 +303,10 @@ Controllers.handleChat = async function (req, res) {
|
|
|
168
303
|
if (typeof entry.text !== 'string' || entry.text.length > 4000) {
|
|
169
304
|
return res.status(400).json({ error: 'Invalid history text' });
|
|
170
305
|
}
|
|
306
|
+
totalHistorySize += entry.text.length;
|
|
307
|
+
if (totalHistorySize > 50000) {
|
|
308
|
+
return res.status(400).json({ error: 'History too large' });
|
|
309
|
+
}
|
|
171
310
|
// Sanitize: strip null bytes and control characters
|
|
172
311
|
entry.text = entry.text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
173
312
|
// Collapse excessive whitespace (padding attack prevention)
|
|
@@ -178,14 +317,19 @@ Controllers.handleChat = async function (req, res) {
|
|
|
178
317
|
|
|
179
318
|
try {
|
|
180
319
|
const result = await geminiChat.chat(safeName, trimmedQuestion, history || [], tier);
|
|
320
|
+
// Record actual token usage after successful AI response
|
|
321
|
+
// Sanitize: clamp to [0, 1000000] to prevent NaN/negative/absurd values
|
|
322
|
+
const tokensUsed = Math.max(0, Math.min(parseInt(result.tokensUsed, 10) || 0, 1000000));
|
|
323
|
+
const updatedQuota = await recordQuotaUsage(req.uid, tier, tokensUsed);
|
|
181
324
|
return res.json({
|
|
182
325
|
answer: result.text,
|
|
183
326
|
injectionWarning: result.suspicious || false,
|
|
184
|
-
quota:
|
|
327
|
+
quota: updatedQuota,
|
|
185
328
|
});
|
|
186
329
|
} catch (err) {
|
|
187
330
|
console.error('[PDF-Secure] Chat error:', err.message, err.status || '', err.code || '');
|
|
188
|
-
|
|
331
|
+
// On error, no tokens were used — return pre-call quota
|
|
332
|
+
const quota = { used: quotaResult.used, max: quotaResult.max, resetsIn: quotaResult.resetsIn };
|
|
189
333
|
|
|
190
334
|
if (err.message === 'File not found') {
|
|
191
335
|
return res.status(404).json({ error: 'PDF bulunamadı.', quota });
|
|
@@ -224,6 +368,9 @@ Controllers.getSuggestions = async function (req, res) {
|
|
|
224
368
|
return res.status(403).json({ error: 'Premium/VIP only' });
|
|
225
369
|
}
|
|
226
370
|
|
|
371
|
+
const isVip = isVipMember || isAdmin;
|
|
372
|
+
const tier = isVip ? 'vip' : 'premium';
|
|
373
|
+
|
|
227
374
|
// Simple rate limit: 1 request per 12 seconds per user
|
|
228
375
|
const now = Date.now();
|
|
229
376
|
const lastReq = suggestionsRateLimit.get(req.uid) || 0;
|
|
@@ -243,7 +390,8 @@ Controllers.getSuggestions = async function (req, res) {
|
|
|
243
390
|
|
|
244
391
|
try {
|
|
245
392
|
const suggestions = await geminiChat.generateSuggestions(safeName);
|
|
246
|
-
|
|
393
|
+
const quotaUsage = await getQuotaUsage(req.uid, tier);
|
|
394
|
+
return res.json({ suggestions, quota: quotaUsage });
|
|
247
395
|
} catch (err) {
|
|
248
396
|
console.error('[PDF-Secure] Suggestions error:', err.message);
|
|
249
397
|
return res.json({ suggestions: [] });
|
package/lib/gemini-chat.js
CHANGED
|
@@ -140,25 +140,82 @@ async function getOrUploadPdf(filename) {
|
|
|
140
140
|
throw new Error('PDF too large for AI chat');
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// Upload to Gemini File API — file is stored server-side, only URI is sent per request
|
|
144
|
+
console.log('[PDF-Secure] === FILE API UPLOAD START ===');
|
|
145
|
+
console.log('[PDF-Secure] Filename:', filename);
|
|
146
|
+
console.log('[PDF-Secure] FilePath:', filePath);
|
|
147
|
+
console.log('[PDF-Secure] FileSize:', stats.size, 'bytes');
|
|
148
|
+
console.log('[PDF-Secure] ai.files exists:', !!ai.files);
|
|
149
|
+
console.log('[PDF-Secure] ai.files.upload exists:', typeof (ai.files && ai.files.upload));
|
|
150
|
+
|
|
151
|
+
const fileBuffer = await fs.promises.readFile(filePath);
|
|
152
|
+
console.log('[PDF-Secure] File read OK, buffer size:', fileBuffer.length);
|
|
153
|
+
|
|
154
|
+
// Try upload with multiple approaches
|
|
155
|
+
let uploadResult;
|
|
156
|
+
const uploadErrors = [];
|
|
157
|
+
|
|
158
|
+
// Approach 1: file path string
|
|
159
|
+
console.log('[PDF-Secure] Trying approach 1: file path string...');
|
|
143
160
|
try {
|
|
144
|
-
|
|
145
|
-
const uploadResult = await ai.files.upload({
|
|
161
|
+
uploadResult = await ai.files.upload({
|
|
146
162
|
file: filePath,
|
|
147
|
-
config: { mimeType: 'application/pdf' },
|
|
163
|
+
config: { mimeType: 'application/pdf', displayName: filename },
|
|
148
164
|
});
|
|
165
|
+
console.log('[PDF-Secure] Approach 1 SUCCESS! Result:', JSON.stringify(uploadResult).slice(0, 500));
|
|
166
|
+
} catch (err1) {
|
|
167
|
+
console.error('[PDF-Secure] Approach 1 FAILED:', err1.message);
|
|
168
|
+
console.error('[PDF-Secure] Approach 1 error detail:', err1.status || '', err1.code || '', err1.stack?.split('\n').slice(0, 3).join(' | '));
|
|
169
|
+
uploadErrors.push('path: ' + err1.message);
|
|
170
|
+
}
|
|
149
171
|
|
|
172
|
+
// Approach 2: Blob (Node 18+)
|
|
173
|
+
if (!uploadResult) {
|
|
174
|
+
console.log('[PDF-Secure] Trying approach 2: Blob...');
|
|
175
|
+
console.log('[PDF-Secure] Blob available:', typeof Blob !== 'undefined');
|
|
176
|
+
try {
|
|
177
|
+
const blob = new Blob([fileBuffer], { type: 'application/pdf' });
|
|
178
|
+
uploadResult = await ai.files.upload({
|
|
179
|
+
file: blob,
|
|
180
|
+
config: { mimeType: 'application/pdf', displayName: filename },
|
|
181
|
+
});
|
|
182
|
+
console.log('[PDF-Secure] Approach 2 SUCCESS! Result:', JSON.stringify(uploadResult).slice(0, 500));
|
|
183
|
+
} catch (err2) {
|
|
184
|
+
console.error('[PDF-Secure] Approach 2 FAILED:', err2.message);
|
|
185
|
+
uploadErrors.push('blob: ' + err2.message);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Approach 3: Buffer directly
|
|
190
|
+
if (!uploadResult) {
|
|
191
|
+
console.log('[PDF-Secure] Trying approach 3: Buffer...');
|
|
192
|
+
try {
|
|
193
|
+
uploadResult = await ai.files.upload({
|
|
194
|
+
file: fileBuffer,
|
|
195
|
+
config: { mimeType: 'application/pdf', displayName: filename },
|
|
196
|
+
});
|
|
197
|
+
console.log('[PDF-Secure] Approach 3 SUCCESS! Result:', JSON.stringify(uploadResult).slice(0, 500));
|
|
198
|
+
} catch (err3) {
|
|
199
|
+
console.error('[PDF-Secure] Approach 3 FAILED:', err3.message);
|
|
200
|
+
uploadErrors.push('buffer: ' + err3.message);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (uploadResult && uploadResult.uri) {
|
|
150
205
|
const fileUri = uploadResult.uri;
|
|
151
206
|
const mimeType = uploadResult.mimeType || 'application/pdf';
|
|
152
|
-
console.log('[PDF-Secure]
|
|
207
|
+
console.log('[PDF-Secure] === UPLOAD FINAL: SUCCESS ===', fileUri);
|
|
153
208
|
|
|
154
209
|
fileUploadCache.set(filename, { fileUri, mimeType, cachedAt: Date.now() });
|
|
155
210
|
return { type: 'fileData', fileUri, mimeType };
|
|
156
|
-
} catch (uploadErr) {
|
|
157
|
-
// Fallback: use inline base64 (works but uses many tokens)
|
|
158
|
-
console.warn('[PDF-Secure] File API upload failed, falling back to inline:', uploadErr.message);
|
|
159
|
-
const base64 = await getPdfBase64(filename);
|
|
160
|
-
return { type: 'inlineData', mimeType: 'application/pdf', data: base64 };
|
|
161
211
|
}
|
|
212
|
+
|
|
213
|
+
// All upload methods failed
|
|
214
|
+
console.error('[PDF-Secure] === UPLOAD FINAL: ALL FAILED ===');
|
|
215
|
+
console.error('[PDF-Secure] Errors:', uploadErrors.join(' | '));
|
|
216
|
+
console.error('[PDF-Secure] Falling back to inline base64 (will use many tokens!)');
|
|
217
|
+
const base64 = fileBuffer.toString('base64');
|
|
218
|
+
return { type: 'inlineData', mimeType: 'application/pdf', data: base64 };
|
|
162
219
|
}
|
|
163
220
|
|
|
164
221
|
// Read PDF and cache base64 in memory (fallback for File API failure)
|
|
@@ -253,7 +310,10 @@ GeminiChat.chat = async function (filename, question, history, tier) {
|
|
|
253
310
|
throw new Error('Empty response from AI');
|
|
254
311
|
}
|
|
255
312
|
|
|
256
|
-
|
|
313
|
+
const tokensUsed = response?.usageMetadata?.candidatesTokenCount || 0;
|
|
314
|
+
const result = sanitizeAiOutput(text);
|
|
315
|
+
result.tokensUsed = tokensUsed;
|
|
316
|
+
return result;
|
|
257
317
|
};
|
|
258
318
|
|
|
259
319
|
// Generate AI-powered question suggestions for a PDF
|
package/library.js
CHANGED
|
@@ -73,6 +73,9 @@ plugin.init = async (params) => {
|
|
|
73
73
|
geminiChat.init(pluginSettings.geminiApiKey);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Apply admin-configured quota settings
|
|
77
|
+
controllers.initQuotaSettings(pluginSettings);
|
|
78
|
+
|
|
76
79
|
const watermarkEnabled = pluginSettings.watermarkEnabled === 'on';
|
|
77
80
|
|
|
78
81
|
// Admin page route
|
|
@@ -158,6 +161,7 @@ plugin.init = async (params) => {
|
|
|
158
161
|
isPremium,
|
|
159
162
|
isVip,
|
|
160
163
|
isLite,
|
|
164
|
+
tier: isVip ? 'vip' : (isPremium ? 'premium' : 'free'),
|
|
161
165
|
uid: req.uid,
|
|
162
166
|
totalPages,
|
|
163
167
|
chatEnabled: geminiChat.isAvailable(),
|
package/package.json
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
Grup Adi
|
|
42
42
|
</label>
|
|
43
43
|
<input type="text" id="vipGroup" name="vipGroup" data-key="vipGroup" title="VIP Group Name" class="form-control" placeholder="VIP" value="VIP">
|
|
44
|
-
<div class="form-text">Premium ozellikleri + VIP rozeti, yuksek token
|
|
44
|
+
<div class="form-text">Premium ozellikleri + VIP rozeti, yuksek token limitleri.</div>
|
|
45
45
|
</div>
|
|
46
46
|
|
|
47
47
|
<div class="mb-0">
|
|
@@ -96,12 +96,47 @@
|
|
|
96
96
|
</div>
|
|
97
97
|
</div>
|
|
98
98
|
|
|
99
|
-
<
|
|
100
|
-
|
|
99
|
+
<hr class="my-3">
|
|
100
|
+
<h6 class="fw-semibold mb-3" style="font-size:13px;">Token Kota Ayarlari</h6>
|
|
101
|
+
|
|
102
|
+
<div class="row g-3 mb-3">
|
|
103
|
+
<div class="col-6">
|
|
104
|
+
<label class="form-label fw-medium" for="quotaPremiumTokens" style="font-size:13px;">
|
|
105
|
+
<span class="badge text-bg-primary me-1" style="font-size:9px;">PREMIUM</span>
|
|
106
|
+
Token Limiti
|
|
107
|
+
</label>
|
|
108
|
+
<div class="input-group input-group-sm">
|
|
109
|
+
<input type="number" id="quotaPremiumTokens" name="quotaPremiumTokens" data-key="quotaPremiumTokens" class="form-control" placeholder="200000" min="10000" step="10000">
|
|
110
|
+
<span class="input-group-text" style="font-size:12px;">token</span>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="form-text" style="font-size:11px;">Varsayilan: 200.000 (~100 mesaj)</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="col-6">
|
|
115
|
+
<label class="form-label fw-medium" for="quotaVipTokens" style="font-size:13px;">
|
|
116
|
+
<span class="badge me-1" style="font-size:9px;background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff;">VIP</span>
|
|
117
|
+
Token Limiti
|
|
118
|
+
</label>
|
|
119
|
+
<div class="input-group input-group-sm">
|
|
120
|
+
<input type="number" id="quotaVipTokens" name="quotaVipTokens" data-key="quotaVipTokens" class="form-control" placeholder="750000" min="10000" step="10000">
|
|
121
|
+
<span class="input-group-text" style="font-size:12px;">token</span>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="form-text" style="font-size:11px;">Varsayilan: 750.000 (~375 mesaj)</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="mb-3">
|
|
127
|
+
<label class="form-label fw-medium" for="quotaWindowHours" style="font-size:13px;">Pencere Suresi</label>
|
|
128
|
+
<div class="input-group input-group-sm" style="max-width:180px;">
|
|
129
|
+
<input type="number" id="quotaWindowHours" name="quotaWindowHours" data-key="quotaWindowHours" class="form-control" placeholder="4" min="1" max="24" step="1">
|
|
130
|
+
<span class="input-group-text" style="font-size:12px;">saat</span>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="form-text" style="font-size:11px;">Varsayilan: 4 saat. Kota bu sure icerisinde sifirlanir.</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div class="alert alert-light border d-flex gap-2 mb-0" role="alert" style="font-size:12px;">
|
|
136
|
+
<svg viewBox="0 0 24 24" style="width:16px;height:16px;fill:#0d6efd;flex-shrink:0;margin-top:1px;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
101
137
|
<div>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
<strong>VIP</strong> — 50 mesaj/dk, 4096 token, 30 gecmis
|
|
138
|
+
Sadece output (yanit) tokenleri sayilir. PDF ve soru tokenleri dahil degildir.<br>
|
|
139
|
+
Ortalama mesaj ~1000-2000 token, uzun ozet ~3000-4000 token tuketir.
|
|
105
140
|
</div>
|
|
106
141
|
</div>
|
|
107
142
|
</div>
|
package/static/viewer.html
CHANGED
|
@@ -1048,6 +1048,8 @@
|
|
|
1048
1048
|
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
|
|
1049
1049
|
color: #1a1a1a;
|
|
1050
1050
|
text-decoration: none;
|
|
1051
|
+
border: none;
|
|
1052
|
+
cursor: pointer;
|
|
1051
1053
|
border-radius: 10px;
|
|
1052
1054
|
font-size: 14px;
|
|
1053
1055
|
font-weight: 700;
|
|
@@ -1092,6 +1094,7 @@
|
|
|
1092
1094
|
color: #ccc;
|
|
1093
1095
|
font-size: 13px;
|
|
1094
1096
|
font-weight: 600;
|
|
1097
|
+
cursor: pointer;
|
|
1095
1098
|
border-radius: 10px;
|
|
1096
1099
|
border: 1.5px solid rgba(255, 255, 255, 0.15);
|
|
1097
1100
|
text-decoration: none;
|
|
@@ -1105,6 +1108,59 @@
|
|
|
1105
1108
|
background: rgba(255, 255, 255, 0.05);
|
|
1106
1109
|
}
|
|
1107
1110
|
|
|
1111
|
+
/* Quota exhausted upsell (in-chat) */
|
|
1112
|
+
.chatQuotaExhausted {
|
|
1113
|
+
display: flex;
|
|
1114
|
+
flex-direction: column;
|
|
1115
|
+
align-items: center;
|
|
1116
|
+
text-align: center;
|
|
1117
|
+
padding: 24px 16px;
|
|
1118
|
+
margin: 8px;
|
|
1119
|
+
background: rgba(255, 215, 0, 0.06);
|
|
1120
|
+
border: 1px solid rgba(255, 215, 0, 0.15);
|
|
1121
|
+
border-radius: 12px;
|
|
1122
|
+
}
|
|
1123
|
+
.quotaExhaustedIcon { margin-bottom: 10px; opacity: 0.9; }
|
|
1124
|
+
.quotaExhaustedTitle {
|
|
1125
|
+
font-size: 16px;
|
|
1126
|
+
font-weight: 700;
|
|
1127
|
+
color: #ffd700;
|
|
1128
|
+
margin-bottom: 6px;
|
|
1129
|
+
}
|
|
1130
|
+
.quotaExhaustedText {
|
|
1131
|
+
font-size: 13px;
|
|
1132
|
+
color: #a0a0a0;
|
|
1133
|
+
line-height: 1.5;
|
|
1134
|
+
margin-bottom: 14px;
|
|
1135
|
+
max-width: 240px;
|
|
1136
|
+
}
|
|
1137
|
+
.quotaExhaustedText strong { color: #ffd700; }
|
|
1138
|
+
.quotaExhaustedBtn {
|
|
1139
|
+
display: inline-flex;
|
|
1140
|
+
align-items: center;
|
|
1141
|
+
gap: 6px;
|
|
1142
|
+
padding: 10px 24px;
|
|
1143
|
+
background: linear-gradient(135deg, #ffd700, #ffaa00);
|
|
1144
|
+
color: #1a1a1a;
|
|
1145
|
+
text-decoration: none;
|
|
1146
|
+
border: none;
|
|
1147
|
+
cursor: pointer;
|
|
1148
|
+
border-radius: 10px;
|
|
1149
|
+
font-size: 14px;
|
|
1150
|
+
font-weight: 700;
|
|
1151
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
1152
|
+
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
|
|
1153
|
+
}
|
|
1154
|
+
.quotaExhaustedBtn:hover {
|
|
1155
|
+
transform: translateY(-2px);
|
|
1156
|
+
box-shadow: 0 6px 20px rgba(255, 215, 0, 0.4);
|
|
1157
|
+
}
|
|
1158
|
+
.quotaExhaustedNote {
|
|
1159
|
+
font-size: 11px;
|
|
1160
|
+
color: #666;
|
|
1161
|
+
margin-top: 10px;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1108
1164
|
/* PRO badge on chat button for non-premium */
|
|
1109
1165
|
#chatBtn {
|
|
1110
1166
|
position: relative;
|
|
@@ -2800,7 +2856,7 @@
|
|
|
2800
2856
|
<span id="chatQuotaText"></span>
|
|
2801
2857
|
</div>
|
|
2802
2858
|
<div id="chatInputArea">
|
|
2803
|
-
<textarea id="chatInput" placeholder="Bu PDF hakkında bir soru sorun..." rows="1"></textarea>
|
|
2859
|
+
<textarea id="chatInput" placeholder="Bu PDF hakkında bir soru sorun..." rows="1" maxlength="2000"></textarea>
|
|
2804
2860
|
<button id="chatSendBtn">
|
|
2805
2861
|
<svg viewBox="0 0 24 24">
|
|
2806
2862
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
|
@@ -4035,13 +4091,28 @@
|
|
|
4035
4091
|
var quotaFillEl = document.getElementById('chatQuotaFill');
|
|
4036
4092
|
var quotaTextEl = document.getElementById('chatQuotaText');
|
|
4037
4093
|
|
|
4038
|
-
|
|
4094
|
+
// Format milliseconds to human-readable time string
|
|
4095
|
+
function formatResetsIn(ms) {
|
|
4096
|
+
if (!ms || ms <= 0) return '';
|
|
4097
|
+
var totalMin = Math.ceil(ms / 60000);
|
|
4098
|
+
if (totalMin < 60) return totalMin + 'dk';
|
|
4099
|
+
var h = Math.floor(totalMin / 60);
|
|
4100
|
+
var m = totalMin % 60;
|
|
4101
|
+
return m > 0 ? (h + 's ' + m + 'dk') : (h + 's');
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
function updateQuotaBar(used, max, resetsIn) {
|
|
4039
4105
|
if (!quotaBarEl || !max) return;
|
|
4040
|
-
var
|
|
4041
|
-
var pct = Math.min(100, (used / max) * 100);
|
|
4106
|
+
var pct = Math.min(100, Math.round((used / max) * 100));
|
|
4042
4107
|
|
|
4043
4108
|
quotaFillEl.style.width = pct + '%';
|
|
4044
|
-
|
|
4109
|
+
|
|
4110
|
+
// Show percentage only (no raw token numbers — users don't need to know)
|
|
4111
|
+
var text = '%' + pct + ' kullan\u0131ld\u0131';
|
|
4112
|
+
if (used > 0 && resetsIn > 0) {
|
|
4113
|
+
text += ' \u2022 ' + formatResetsIn(resetsIn) + ' sonra yenilenir';
|
|
4114
|
+
}
|
|
4115
|
+
quotaTextEl.textContent = text;
|
|
4045
4116
|
|
|
4046
4117
|
// Color states
|
|
4047
4118
|
quotaFillEl.classList.remove('warning', 'critical');
|
|
@@ -4098,6 +4169,14 @@
|
|
|
4098
4169
|
} else {
|
|
4099
4170
|
existingChips.forEach(function (c) { c.classList.remove('loading'); });
|
|
4100
4171
|
}
|
|
4172
|
+
// Show initial quota bar from suggestions response
|
|
4173
|
+
if (data.quota) {
|
|
4174
|
+
updateQuotaBar(data.quota.used, data.quota.max, data.quota.resetsIn);
|
|
4175
|
+
// Proactive lock: if quota already exhausted on page load, lock input immediately
|
|
4176
|
+
if (data.quota.max > 0 && data.quota.used >= data.quota.max) {
|
|
4177
|
+
showQuotaExhaustedUpsell(_cfg.tier || 'premium', data.quota.resetsIn);
|
|
4178
|
+
}
|
|
4179
|
+
}
|
|
4101
4180
|
})
|
|
4102
4181
|
.catch(function () {
|
|
4103
4182
|
existingChips.forEach(function (c) { c.classList.remove('loading'); });
|
|
@@ -4126,26 +4205,158 @@
|
|
|
4126
4205
|
function showChatUpsell() {
|
|
4127
4206
|
var uid = (_cfg && _cfg.uid) || 0;
|
|
4128
4207
|
var checkoutUrl = 'https://forum.ieu.app/pay/checkout?uid=' + uid;
|
|
4129
|
-
|
|
4208
|
+
var materialUrl = 'https://forum.ieu.app/material-info';
|
|
4209
|
+
|
|
4210
|
+
// Build using DOM methods — MutationObserver strips <a> tags
|
|
4211
|
+
var upsell = document.createElement('div');
|
|
4130
4212
|
upsell.className = 'chatUpsell';
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4213
|
+
|
|
4214
|
+
// Icon
|
|
4215
|
+
var iconDiv = document.createElement('div');
|
|
4216
|
+
iconDiv.className = 'chatUpsellIcon';
|
|
4217
|
+
var iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
4218
|
+
iconSvg.setAttribute('viewBox', '0 0 24 24');
|
|
4219
|
+
iconSvg.setAttribute('width', '48');
|
|
4220
|
+
iconSvg.setAttribute('height', '48');
|
|
4221
|
+
iconSvg.setAttribute('fill', '#ffd700');
|
|
4222
|
+
var iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
4223
|
+
iconPath.setAttribute('d', 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z');
|
|
4224
|
+
iconSvg.appendChild(iconPath);
|
|
4225
|
+
iconDiv.appendChild(iconSvg);
|
|
4226
|
+
upsell.appendChild(iconDiv);
|
|
4227
|
+
|
|
4228
|
+
// Title
|
|
4229
|
+
var title = document.createElement('div');
|
|
4230
|
+
title.className = 'chatUpsellTitle';
|
|
4231
|
+
title.textContent = 'PDF Chat';
|
|
4232
|
+
upsell.appendChild(title);
|
|
4233
|
+
|
|
4234
|
+
// Text
|
|
4235
|
+
var text = document.createElement('div');
|
|
4236
|
+
text.className = 'chatUpsellText';
|
|
4237
|
+
text.innerHTML = 'PDF\'ler hakk\u0131nda <strong>yapay zeka</strong> ile sohbet edebilir, sorular sorabilirsiniz. Bu \u00f6zellik <strong>Premium</strong> ve <strong>VIP</strong> \u00fcyelere \u00f6zeldir.';
|
|
4238
|
+
upsell.appendChild(text);
|
|
4239
|
+
|
|
4240
|
+
// Actions
|
|
4241
|
+
var actions = document.createElement('div');
|
|
4242
|
+
actions.className = 'chatUpsellActions';
|
|
4243
|
+
|
|
4244
|
+
// Primary button (upgrade)
|
|
4245
|
+
var btn1 = document.createElement('button');
|
|
4246
|
+
btn1.className = 'chatUpsellBtn';
|
|
4247
|
+
var btn1Svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
4248
|
+
btn1Svg.setAttribute('viewBox', '0 0 24 24');
|
|
4249
|
+
btn1Svg.setAttribute('width', '18');
|
|
4250
|
+
btn1Svg.setAttribute('height', '18');
|
|
4251
|
+
btn1Svg.setAttribute('fill', '#1a1a1a');
|
|
4252
|
+
var btn1Path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
4253
|
+
btn1Path.setAttribute('d', 'M16 18v2H8v-2h8zM11 7.99V16h2V7.99h3L12 4l-4 3.99h3z');
|
|
4254
|
+
btn1Svg.appendChild(btn1Path);
|
|
4255
|
+
btn1.appendChild(btn1Svg);
|
|
4256
|
+
btn1.appendChild(document.createTextNode(' Hesab\u0131n\u0131 Y\u00fckselt'));
|
|
4257
|
+
btn1.addEventListener('click', function () { window.open(checkoutUrl, '_blank'); });
|
|
4258
|
+
actions.appendChild(btn1);
|
|
4259
|
+
|
|
4260
|
+
// Divider
|
|
4261
|
+
var divider = document.createElement('div');
|
|
4262
|
+
divider.className = 'chatUpsellDivider';
|
|
4263
|
+
divider.textContent = 'ya da';
|
|
4264
|
+
actions.appendChild(divider);
|
|
4265
|
+
|
|
4266
|
+
// Secondary button (upload material)
|
|
4267
|
+
var btn2 = document.createElement('button');
|
|
4268
|
+
btn2.className = 'chatUpsellBtnSecondary';
|
|
4269
|
+
var btn2Svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
4270
|
+
btn2Svg.setAttribute('viewBox', '0 0 24 24');
|
|
4271
|
+
btn2Svg.setAttribute('width', '16');
|
|
4272
|
+
btn2Svg.setAttribute('height', '16');
|
|
4273
|
+
btn2Svg.setAttribute('fill', '#ccc');
|
|
4274
|
+
var btn2Path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
4275
|
+
btn2Path.setAttribute('d', 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11zM8 15.01l1.41 1.41L11 14.84V19h2v-4.16l1.59 1.59L16 15.01 12.01 11 8 15.01z');
|
|
4276
|
+
btn2Svg.appendChild(btn2Path);
|
|
4277
|
+
btn2.appendChild(btn2Svg);
|
|
4278
|
+
btn2.appendChild(document.createTextNode(' Materyal Y\u00fckle'));
|
|
4279
|
+
btn2.addEventListener('click', function () { window.open(materialUrl, '_blank'); });
|
|
4280
|
+
actions.appendChild(btn2);
|
|
4281
|
+
|
|
4282
|
+
upsell.appendChild(actions);
|
|
4146
4283
|
chatMessagesEl.appendChild(upsell);
|
|
4147
4284
|
}
|
|
4148
4285
|
|
|
4286
|
+
function showQuotaExhaustedUpsell(tier, resetsIn) {
|
|
4287
|
+
var uid = (_cfg && _cfg.uid) || 0;
|
|
4288
|
+
var checkoutUrl = 'https://forum.ieu.app/pay/checkout?uid=' + uid;
|
|
4289
|
+
var resetText = resetsIn ? formatResetsIn(resetsIn) : '~4 saat';
|
|
4290
|
+
var msg = document.createElement('div');
|
|
4291
|
+
msg.className = 'chatMsg chatQuotaExhausted';
|
|
4292
|
+
|
|
4293
|
+
// Build using DOM methods to avoid MutationObserver stripping <a> tags
|
|
4294
|
+
var icon = document.createElement('div');
|
|
4295
|
+
icon.className = 'quotaExhaustedIcon';
|
|
4296
|
+
var iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
4297
|
+
iconSvg.setAttribute('viewBox', '0 0 24 24');
|
|
4298
|
+
iconSvg.setAttribute('width', '36');
|
|
4299
|
+
iconSvg.setAttribute('height', '36');
|
|
4300
|
+
var iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
4301
|
+
|
|
4302
|
+
if (tier === 'vip') {
|
|
4303
|
+
iconSvg.setAttribute('fill', '#f59e0b');
|
|
4304
|
+
iconPath.setAttribute('d', 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z');
|
|
4305
|
+
} else {
|
|
4306
|
+
iconSvg.setAttribute('fill', '#ffd700');
|
|
4307
|
+
iconPath.setAttribute('d', 'M12 2L9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61z');
|
|
4308
|
+
}
|
|
4309
|
+
iconSvg.appendChild(iconPath);
|
|
4310
|
+
icon.appendChild(iconSvg);
|
|
4311
|
+
msg.appendChild(icon);
|
|
4312
|
+
|
|
4313
|
+
var title = document.createElement('div');
|
|
4314
|
+
title.className = 'quotaExhaustedTitle';
|
|
4315
|
+
title.textContent = 'Mesaj kotas\u0131 doldu';
|
|
4316
|
+
msg.appendChild(title);
|
|
4317
|
+
|
|
4318
|
+
var text = document.createElement('div');
|
|
4319
|
+
text.className = 'quotaExhaustedText';
|
|
4320
|
+
if (tier === 'vip') {
|
|
4321
|
+
text.textContent = 'Mesaj hakk\u0131n\u0131z\u0131 kulland\u0131n\u0131z. ' + resetText + ' sonra s\u0131f\u0131rlan\u0131r.';
|
|
4322
|
+
} else {
|
|
4323
|
+
text.innerHTML = 'Kullan\u0131m hakk\u0131n\u0131z doldu. <strong>VIP</strong>\u2019e y\u00fckselterek \u00e7ok daha fazla kullan\u0131m hakk\u0131 kazan\u0131n!';
|
|
4324
|
+
}
|
|
4325
|
+
msg.appendChild(text);
|
|
4326
|
+
|
|
4327
|
+
if (tier !== 'vip') {
|
|
4328
|
+
// Use <button> + addEventListener instead of <a> to avoid
|
|
4329
|
+
// MutationObserver stripping (it removes <a> tags as dangerous)
|
|
4330
|
+
var btn = document.createElement('button');
|
|
4331
|
+
btn.className = 'quotaExhaustedBtn';
|
|
4332
|
+
var btnSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
4333
|
+
btnSvg.setAttribute('viewBox', '0 0 24 24');
|
|
4334
|
+
btnSvg.setAttribute('width', '18');
|
|
4335
|
+
btnSvg.setAttribute('height', '18');
|
|
4336
|
+
btnSvg.setAttribute('fill', '#1a1a1a');
|
|
4337
|
+
var btnPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
4338
|
+
btnPath.setAttribute('d', 'M16 18v2H8v-2h8zM11 7.99V16h2V7.99h3L12 4l-4 3.99h3z');
|
|
4339
|
+
btnSvg.appendChild(btnPath);
|
|
4340
|
+
btn.appendChild(btnSvg);
|
|
4341
|
+
btn.appendChild(document.createTextNode(' Hesab\u0131n\u0131 Y\u00fckselt'));
|
|
4342
|
+
btn.addEventListener('click', function () { window.open(checkoutUrl, '_blank'); });
|
|
4343
|
+
msg.appendChild(btn);
|
|
4344
|
+
}
|
|
4345
|
+
|
|
4346
|
+
var note = document.createElement('div');
|
|
4347
|
+
note.className = 'quotaExhaustedNote';
|
|
4348
|
+
note.textContent = resetText + ' sonra s\u0131f\u0131rlan\u0131r';
|
|
4349
|
+
msg.appendChild(note);
|
|
4350
|
+
|
|
4351
|
+
chatMessagesEl.appendChild(msg);
|
|
4352
|
+
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
|
|
4353
|
+
|
|
4354
|
+
// Disable input area
|
|
4355
|
+
chatInputEl.disabled = true;
|
|
4356
|
+
chatSendBtnEl.disabled = true;
|
|
4357
|
+
chatInputEl.placeholder = 'Mesaj kotas\u0131 doldu';
|
|
4358
|
+
}
|
|
4359
|
+
|
|
4149
4360
|
async function sendChatMessage() {
|
|
4150
4361
|
if (!chatIsPremium) return;
|
|
4151
4362
|
if (chatSending) return;
|
|
@@ -4180,7 +4391,12 @@
|
|
|
4180
4391
|
const data = await resp.json();
|
|
4181
4392
|
// Update quota bar from response
|
|
4182
4393
|
if (data.quota) {
|
|
4183
|
-
updateQuotaBar(data.quota.used, data.quota.max);
|
|
4394
|
+
updateQuotaBar(data.quota.used, data.quota.max, data.quota.resetsIn);
|
|
4395
|
+
}
|
|
4396
|
+
// Daily quota exhausted — show upsell
|
|
4397
|
+
if (resp.status === 429 && data.quotaExhausted) {
|
|
4398
|
+
showQuotaExhaustedUpsell(data.tier, data.quota && data.quota.resetsIn);
|
|
4399
|
+
return;
|
|
4184
4400
|
}
|
|
4185
4401
|
if (resp.ok && data.answer) {
|
|
4186
4402
|
addChatMessage('ai', data.answer, data.injectionWarning);
|
|
@@ -4198,9 +4414,12 @@
|
|
|
4198
4414
|
addChatMessage('error', 'Bağlantı hatası. Tekrar deneyin.');
|
|
4199
4415
|
} finally {
|
|
4200
4416
|
chatSending = false;
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4417
|
+
// Don't re-enable if quota exhausted (input was permanently disabled)
|
|
4418
|
+
if (!chatInputEl.disabled) {
|
|
4419
|
+
chatSendBtnEl.disabled = false;
|
|
4420
|
+
updateSendBtnState();
|
|
4421
|
+
chatInputEl.focus();
|
|
4422
|
+
}
|
|
4204
4423
|
}
|
|
4205
4424
|
}
|
|
4206
4425
|
|
package/test/image.png
ADDED
|
Binary file
|
package/static/image.png
DELETED
|
Binary file
|