Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- web/app.py +8 -0
- web/js/api.js +3 -8
- web/js/chat.js +26 -275
- web/js/entry-gate.js +60 -0
- web/js/feedback.js +122 -0
- web/js/main.js +2 -61
- web/js/state.js +16 -0
- web/js/ui.js +111 -0
- web/js/utils.js +25 -0
web/app.py
CHANGED
|
@@ -19,6 +19,14 @@ Prerequisites:
|
|
| 19 |
=============================================================
|
| 20 |
"""
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# Import routes module to register all routes
|
| 23 |
from routes import pages, auth, health, chat
|
| 24 |
|
|
|
|
| 19 |
=============================================================
|
| 20 |
"""
|
| 21 |
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
| 27 |
+
if str(ROOT_DIR) not in sys.path:
|
| 28 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 29 |
+
|
| 30 |
# Import routes module to register all routes
|
| 31 |
from routes import pages, auth, health, chat
|
| 32 |
|
web/js/api.js
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export const API_BASE = window.location.origin + '/api';
|
| 2 |
-
export const SESSION_ID = 'session_' + Math.random().toString(36).slice(2, 9);
|
| 3 |
|
| 4 |
const fingerprintPromise = import('/vendor/fingerprintjs/fp.esm.js').then((FingerprintJS) =>
|
| 5 |
FingerprintJS.load({ monitoring: false })
|
| 6 |
);
|
| 7 |
|
| 8 |
-
export const quotaInfo = {
|
| 9 |
-
daily_used: 0,
|
| 10 |
-
minute_used: 0,
|
| 11 |
-
daily_limit: 40,
|
| 12 |
-
minute_limit: 5,
|
| 13 |
-
};
|
| 14 |
-
|
| 15 |
let cachedFingerprint = '';
|
| 16 |
|
| 17 |
export function setQuotaFromStatus(data) {
|
|
|
|
| 1 |
+
import { SESSION_ID, quotaInfo } from './state.js';
|
| 2 |
+
|
| 3 |
+
export { SESSION_ID };
|
| 4 |
export const API_BASE = window.location.origin + '/api';
|
|
|
|
| 5 |
|
| 6 |
const fingerprintPromise = import('/vendor/fingerprintjs/fp.esm.js').then((FingerprintJS) =>
|
| 7 |
FingerprintJS.load({ monitoring: false })
|
| 8 |
);
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
let cachedFingerprint = '';
|
| 11 |
|
| 12 |
export function setQuotaFromStatus(data) {
|
web/js/chat.js
CHANGED
|
@@ -1,287 +1,36 @@
|
|
| 1 |
-
import { API_BASE,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
const messagesEl = document.getElementById('messages');
|
| 4 |
const inputEl = document.getElementById('user-input');
|
| 5 |
const sendBtn = document.getElementById('send-btn');
|
| 6 |
const charCount = document.getElementById('char-count');
|
| 7 |
-
const noticeLayer = document.getElementById('notice-layer');
|
| 8 |
-
|
| 9 |
-
let isStreaming = false;
|
| 10 |
-
let lastQuestionIndex = null;
|
| 11 |
-
let limitNoticeVisible = false;
|
| 12 |
-
let congestionNoticeVisible = false;
|
| 13 |
-
const notifiedFallbackModels = new Set();
|
| 14 |
-
|
| 15 |
-
function scrollToBottom() {
|
| 16 |
-
if (messagesEl) {
|
| 17 |
-
messagesEl.scrollTop = messagesEl.scrollHeight;
|
| 18 |
-
}
|
| 19 |
-
}
|
| 20 |
|
| 21 |
function updateQuotaDisplay() {
|
| 22 |
const dailyRemaining = Math.max(quotaInfo.daily_limit - quotaInfo.daily_used, 0);
|
| 23 |
const minuteRemaining = Math.max(quotaInfo.minute_limit - quotaInfo.minute_used, 0);
|
| 24 |
|
| 25 |
if (dailyRemaining <= 10 || minuteRemaining <= 1) {
|
| 26 |
-
showLimitNotice(dailyRemaining);
|
| 27 |
-
}
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
function showLimitNotice(remaining = null) {
|
| 31 |
-
if (!noticeLayer || limitNoticeVisible) return;
|
| 32 |
-
|
| 33 |
-
limitNoticeVisible = true;
|
| 34 |
-
noticeLayer.innerHTML = '';
|
| 35 |
-
|
| 36 |
-
const pill = document.createElement('div');
|
| 37 |
-
pill.className = 'notice-pill';
|
| 38 |
-
const countText = Number.isFinite(remaining) ? ` ${remaining} günlük hakkın kaldı.` : '';
|
| 39 |
-
pill.innerHTML = `
|
| 40 |
-
<span>Hakkın az kaldı.${countText}</span>
|
| 41 |
-
<button class="notice-close" type="button" aria-label="Kapat">×</button>
|
| 42 |
-
`;
|
| 43 |
-
|
| 44 |
-
pill.querySelector('.notice-close')?.addEventListener('click', () => {
|
| 45 |
-
limitNoticeVisible = false;
|
| 46 |
-
pill.remove();
|
| 47 |
-
});
|
| 48 |
-
|
| 49 |
-
noticeLayer.appendChild(pill);
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
function showCongestionNotice() {
|
| 53 |
-
if (!noticeLayer || congestionNoticeVisible) return;
|
| 54 |
-
|
| 55 |
-
congestionNoticeVisible = true;
|
| 56 |
-
const pill = document.createElement('div');
|
| 57 |
-
pill.className = 'notice-pill';
|
| 58 |
-
pill.innerHTML = `
|
| 59 |
-
<span>⚠️ Şu anda yoğunluk var, yanıtlar normalden geç gelebilir.</span>
|
| 60 |
-
<button class="notice-close" type="button" aria-label="Kapat">×</button>
|
| 61 |
-
`;
|
| 62 |
-
|
| 63 |
-
pill.querySelector('.notice-close')?.addEventListener('click', () => {
|
| 64 |
-
congestionNoticeVisible = false;
|
| 65 |
-
pill.remove();
|
| 66 |
-
});
|
| 67 |
-
|
| 68 |
-
noticeLayer.appendChild(pill);
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
function hideCongestionNotice() {
|
| 72 |
-
if (!noticeLayer || !congestionNoticeVisible) return;
|
| 73 |
-
|
| 74 |
-
congestionNoticeVisible = false;
|
| 75 |
-
const pills = noticeLayer.querySelectorAll('.notice-pill');
|
| 76 |
-
pills.forEach((pill) => {
|
| 77 |
-
if (pill.textContent.includes('yoğunluk')) pill.remove();
|
| 78 |
-
});
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
function showModelFallbackNotice(notice) {
|
| 82 |
-
if (!noticeLayer) return;
|
| 83 |
-
|
| 84 |
-
const targetModel = notice?.to_model || 'unknown';
|
| 85 |
-
if (notifiedFallbackModels.has(targetModel)) return;
|
| 86 |
-
notifiedFallbackModels.add(targetModel);
|
| 87 |
-
|
| 88 |
-
const pill = document.createElement('div');
|
| 89 |
-
pill.className = 'notice-pill';
|
| 90 |
-
pill.innerHTML = `
|
| 91 |
-
<span>Yoğunluk nedeniyle farklı bir model kullanılıyor.</span>
|
| 92 |
-
<button class="notice-close" type="button" aria-label="Kapat">×</button>
|
| 93 |
-
`;
|
| 94 |
-
|
| 95 |
-
noticeLayer.appendChild(pill);
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
export function appendMessage(role, text) {
|
| 99 |
-
document.getElementById('welcome-msg')?.remove();
|
| 100 |
-
|
| 101 |
-
const wrap = document.createElement('div');
|
| 102 |
-
wrap.className = `msg-wrap ${role}`;
|
| 103 |
-
|
| 104 |
-
const bubble = document.createElement('div');
|
| 105 |
-
bubble.className = 'bubble';
|
| 106 |
-
bubble.innerHTML = formatText(text);
|
| 107 |
-
|
| 108 |
-
wrap.appendChild(bubble);
|
| 109 |
-
messagesEl?.appendChild(wrap);
|
| 110 |
-
scrollToBottom();
|
| 111 |
-
return bubble;
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
function appendTypingIndicator() {
|
| 115 |
-
document.getElementById('welcome-msg')?.remove();
|
| 116 |
-
|
| 117 |
-
const wrap = document.createElement('div');
|
| 118 |
-
wrap.className = 'msg-wrap bot';
|
| 119 |
-
wrap.id = 'typing-wrap';
|
| 120 |
-
|
| 121 |
-
const indicator = document.createElement('div');
|
| 122 |
-
indicator.className = 'typing-indicator';
|
| 123 |
-
indicator.innerHTML = '<span></span><span></span><span></span>';
|
| 124 |
-
|
| 125 |
-
wrap.appendChild(indicator);
|
| 126 |
-
messagesEl?.appendChild(wrap);
|
| 127 |
-
scrollToBottom();
|
| 128 |
-
return wrap;
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
function formatText(text) {
|
| 132 |
-
return text
|
| 133 |
-
.replace(/&/g, '&')
|
| 134 |
-
.replace(/</g, '<')
|
| 135 |
-
.replace(/>/g, '>')
|
| 136 |
-
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
| 137 |
-
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
| 138 |
-
.replace(/`(.+?)`/g, '<code>$1</code>')
|
| 139 |
-
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
|
| 140 |
-
.replace(/^#{1,3}\s+(.+)$/gm, '<strong>$1</strong>')
|
| 141 |
-
.replace(/^[-•]\s+(.+)$/gm, '• $1')
|
| 142 |
-
.replace(/\n\n/g, '</p><p>')
|
| 143 |
-
.replace(/\n/g, '<br>')
|
| 144 |
-
.replace(/^/, '<p>')
|
| 145 |
-
.replace(/$/, '</p>');
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
function stripReasoningText(text) {
|
| 149 |
-
return text
|
| 150 |
-
.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '')
|
| 151 |
-
.replace(/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi, '')
|
| 152 |
-
.replace(/<think\b[^>]*>[\s\S]*$/gi, '')
|
| 153 |
-
.replace(/<thinking\b[^>]*>[\s\S]*$/gi, '')
|
| 154 |
-
.trim();
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
function sendFeedback(questionIndex, type) {
|
| 158 |
-
const btns = document.querySelectorAll(`[data-qidx="${questionIndex}"] .fb-btn`);
|
| 159 |
-
btns.forEach((button) => {
|
| 160 |
-
const feedbackType = button.dataset.fbType;
|
| 161 |
-
button.classList.toggle('fb-active', feedbackType === type);
|
| 162 |
-
});
|
| 163 |
-
|
| 164 |
-
fetch(`${API_BASE}/chat/feedback`, {
|
| 165 |
-
method: 'POST',
|
| 166 |
-
credentials: 'same-origin',
|
| 167 |
-
headers: { 'Content-Type': 'application/json' },
|
| 168 |
-
body: JSON.stringify({ question_index: questionIndex, feedback: type }),
|
| 169 |
-
}).catch(() => {});
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
function toggleFeedbackBox(questionIndex) {
|
| 173 |
-
const box = document.querySelector(`[data-qidx="${questionIndex}"] .fb-text-box`);
|
| 174 |
-
if (!box) return;
|
| 175 |
-
|
| 176 |
-
const isVisible = box.style.display === 'flex';
|
| 177 |
-
box.style.display = isVisible ? 'none' : 'flex';
|
| 178 |
-
|
| 179 |
-
if (!isVisible) {
|
| 180 |
-
const textarea = box.querySelector('textarea');
|
| 181 |
-
if (textarea) {
|
| 182 |
-
textarea.value = '';
|
| 183 |
-
textarea.focus();
|
| 184 |
-
}
|
| 185 |
}
|
| 186 |
}
|
| 187 |
|
| 188 |
-
function submitFeedbackText(questionIndex) {
|
| 189 |
-
const box = document.querySelector(`[data-qidx="${questionIndex}"] .fb-text-box`);
|
| 190 |
-
if (!box) return;
|
| 191 |
-
|
| 192 |
-
const textarea = box.querySelector('textarea');
|
| 193 |
-
const text = textarea ? textarea.value.trim() : '';
|
| 194 |
-
if (!text) {
|
| 195 |
-
toggleFeedbackBox(questionIndex);
|
| 196 |
-
return;
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
fetch(`${API_BASE}/chat/feedback`, {
|
| 200 |
-
method: 'POST',
|
| 201 |
-
credentials: 'same-origin',
|
| 202 |
-
headers: { 'Content-Type': 'application/json' },
|
| 203 |
-
body: JSON.stringify({ question_index: questionIndex, feedback_text: text }),
|
| 204 |
-
}).catch(() => {});
|
| 205 |
-
|
| 206 |
-
box.style.display = 'none';
|
| 207 |
-
|
| 208 |
-
const bar = box.closest('.fb-bar');
|
| 209 |
-
if (bar) {
|
| 210 |
-
const existing = bar.querySelector('.fb-thanks');
|
| 211 |
-
if (existing) existing.remove();
|
| 212 |
-
|
| 213 |
-
const thanks = document.createElement('div');
|
| 214 |
-
thanks.className = 'fb-thanks';
|
| 215 |
-
thanks.textContent = 'Geri bildiriminiz için teşekkür ederiz.';
|
| 216 |
-
bar.appendChild(thanks);
|
| 217 |
-
setTimeout(() => {
|
| 218 |
-
thanks.remove();
|
| 219 |
-
}, 2500);
|
| 220 |
-
}
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
function createFeedbackBar(questionIndex) {
|
| 224 |
-
const bar = document.createElement('div');
|
| 225 |
-
bar.className = 'fb-bar';
|
| 226 |
-
bar.dataset.qidx = questionIndex;
|
| 227 |
-
|
| 228 |
-
const likeBtn = document.createElement('button');
|
| 229 |
-
likeBtn.className = 'fb-btn';
|
| 230 |
-
likeBtn.dataset.fbType = 'like';
|
| 231 |
-
likeBtn.title = 'Yararlı';
|
| 232 |
-
likeBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>`;
|
| 233 |
-
likeBtn.addEventListener('click', () => sendFeedback(questionIndex, 'like'));
|
| 234 |
-
|
| 235 |
-
const dislikeBtn = document.createElement('button');
|
| 236 |
-
dislikeBtn.className = 'fb-btn';
|
| 237 |
-
dislikeBtn.dataset.fbType = 'dislike';
|
| 238 |
-
dislikeBtn.title = 'Yanlış veya yetersiz';
|
| 239 |
-
dislikeBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V7H7.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3"/></svg>`;
|
| 240 |
-
dislikeBtn.addEventListener('click', () => sendFeedback(questionIndex, 'dislike'));
|
| 241 |
-
|
| 242 |
-
const chatBtn = document.createElement('button');
|
| 243 |
-
chatBtn.className = 'fb-btn';
|
| 244 |
-
chatBtn.title = 'Geri bildirim yaz';
|
| 245 |
-
chatBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
|
| 246 |
-
chatBtn.addEventListener('click', () => toggleFeedbackBox(questionIndex));
|
| 247 |
-
|
| 248 |
-
bar.appendChild(likeBtn);
|
| 249 |
-
bar.appendChild(dislikeBtn);
|
| 250 |
-
bar.appendChild(chatBtn);
|
| 251 |
-
|
| 252 |
-
const textBox = document.createElement('div');
|
| 253 |
-
textBox.className = 'fb-text-box';
|
| 254 |
-
textBox.style.display = 'none';
|
| 255 |
-
textBox.innerHTML = `
|
| 256 |
-
<textarea class="fb-textarea" placeholder="Geri bildiriminizi yazın..." rows="2" maxlength="500"></textarea>
|
| 257 |
-
<div class="fb-text-actions">
|
| 258 |
-
<button class="fb-text-cancel" type="button">İptal</button>
|
| 259 |
-
<button class="fb-text-send" type="button">Gönder</button>
|
| 260 |
-
</div>
|
| 261 |
-
`;
|
| 262 |
-
|
| 263 |
-
textBox.querySelector('.fb-text-send')?.addEventListener('click', () => submitFeedbackText(questionIndex));
|
| 264 |
-
textBox.querySelector('.fb-text-cancel')?.addEventListener('click', () => {
|
| 265 |
-
textBox.style.display = 'none';
|
| 266 |
-
});
|
| 267 |
-
textBox.querySelector('textarea')?.addEventListener('keydown', (event) => {
|
| 268 |
-
if (event.key === 'Enter' && !event.shiftKey) {
|
| 269 |
-
event.preventDefault();
|
| 270 |
-
submitFeedbackText(questionIndex);
|
| 271 |
-
}
|
| 272 |
-
});
|
| 273 |
-
|
| 274 |
-
bar.appendChild(textBox);
|
| 275 |
-
return bar;
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
export async function sendMessage() {
|
| 279 |
if (!inputEl || !sendBtn || !charCount) return;
|
| 280 |
|
| 281 |
const message = inputEl.value.trim();
|
| 282 |
-
if (!message || isStreaming) return;
|
| 283 |
|
| 284 |
-
isStreaming = true;
|
| 285 |
sendBtn.disabled = true;
|
| 286 |
inputEl.value = '';
|
| 287 |
inputEl.style.height = 'auto';
|
|
@@ -300,7 +49,7 @@ export async function sendMessage() {
|
|
| 300 |
|
| 301 |
if (!response.ok) {
|
| 302 |
const data = await response.json().catch(() => ({}));
|
| 303 |
-
if (data.near_limit) showLimitNotice();
|
| 304 |
const errorType = data.error_type || 'technical';
|
| 305 |
const userMsg = errorType === 'quota' ? (data.error || 'Limit aşıldı.') : 'Teknik bir sorun oluştu. Lütfen daha sonra tekrar deneyin.';
|
| 306 |
throw new Error(userMsg);
|
|
@@ -317,9 +66,12 @@ export async function sendMessage() {
|
|
| 317 |
bubble.innerHTML = '<p></p>';
|
| 318 |
|
| 319 |
wrap.appendChild(bubble);
|
|
|
|
| 320 |
messagesEl?.appendChild(wrap);
|
| 321 |
|
| 322 |
-
const reader = response.body.getReader();
|
|
|
|
|
|
|
| 323 |
const decoder = new TextDecoder();
|
| 324 |
let rawText = '';
|
| 325 |
let sseBuffer = '';
|
|
@@ -346,17 +98,16 @@ export async function sendMessage() {
|
|
| 346 |
}
|
| 347 |
|
| 348 |
if (data.congestion) {
|
| 349 |
-
showCongestionNotice();
|
| 350 |
}
|
| 351 |
|
| 352 |
if (data.model_fallback) {
|
| 353 |
-
showModelFallbackNotice(data.model_fallback);
|
| 354 |
}
|
| 355 |
|
| 356 |
if (data.done && data.question_index) {
|
| 357 |
-
lastQuestionIndex = data.question_index;
|
| 358 |
-
|
| 359 |
-
wrap.appendChild(feedbackBar);
|
| 360 |
}
|
| 361 |
} catch {
|
| 362 |
// Ignore incomplete or malformed SSE fragments.
|
|
@@ -383,7 +134,6 @@ export async function sendMessage() {
|
|
| 383 |
|
| 384 |
if (!sseHadError) {
|
| 385 |
await loadAuthStatus();
|
| 386 |
-
setQuotaFromStatus(quotaInfo);
|
| 387 |
updateQuotaDisplay();
|
| 388 |
}
|
| 389 |
} catch (err) {
|
|
@@ -392,7 +142,7 @@ export async function sendMessage() {
|
|
| 392 |
appendMessage('bot', errorText);
|
| 393 |
}
|
| 394 |
|
| 395 |
-
isStreaming = false;
|
| 396 |
sendBtn.disabled = false;
|
| 397 |
inputEl?.focus();
|
| 398 |
scrollToBottom();
|
|
@@ -438,6 +188,7 @@ export async function clearChat() {
|
|
| 438 |
// Continue silently even if the reset request fails.
|
| 439 |
}
|
| 440 |
|
|
|
|
| 441 |
if (messagesEl) {
|
| 442 |
messagesEl.innerHTML = welcomeMarkup(
|
| 443 |
'Sohbet sıfırlandı',
|
|
|
|
| 1 |
+
import { API_BASE, buildApiHeaders, loadAuthStatus } from './api.js';
|
| 2 |
+
import { SESSION_ID, appState, quotaInfo } from './state.js';
|
| 3 |
+
import {
|
| 4 |
+
appendMessage,
|
| 5 |
+
appendTypingIndicator,
|
| 6 |
+
scrollToBottom,
|
| 7 |
+
showCongestionNotice,
|
| 8 |
+
showLimitNotice,
|
| 9 |
+
showModelFallbackNotice,
|
| 10 |
+
} from './ui.js';
|
| 11 |
+
import { createFeedbackBar } from './feedback.js';
|
| 12 |
+
import { formatText, stripReasoningText } from './utils.js';
|
| 13 |
|
|
|
|
| 14 |
const inputEl = document.getElementById('user-input');
|
| 15 |
const sendBtn = document.getElementById('send-btn');
|
| 16 |
const charCount = document.getElementById('char-count');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
function updateQuotaDisplay() {
|
| 19 |
const dailyRemaining = Math.max(quotaInfo.daily_limit - quotaInfo.daily_used, 0);
|
| 20 |
const minuteRemaining = Math.max(quotaInfo.minute_limit - quotaInfo.minute_used, 0);
|
| 21 |
|
| 22 |
if (dailyRemaining <= 10 || minuteRemaining <= 1) {
|
| 23 |
+
showLimitNotice(dailyRemaining, appState);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
export async function sendMessage() {
|
| 28 |
if (!inputEl || !sendBtn || !charCount) return;
|
| 29 |
|
| 30 |
const message = inputEl.value.trim();
|
| 31 |
+
if (!message || appState.isStreaming) return;
|
| 32 |
|
| 33 |
+
appState.isStreaming = true;
|
| 34 |
sendBtn.disabled = true;
|
| 35 |
inputEl.value = '';
|
| 36 |
inputEl.style.height = 'auto';
|
|
|
|
| 49 |
|
| 50 |
if (!response.ok) {
|
| 51 |
const data = await response.json().catch(() => ({}));
|
| 52 |
+
if (data.near_limit) showLimitNotice(null, appState);
|
| 53 |
const errorType = data.error_type || 'technical';
|
| 54 |
const userMsg = errorType === 'quota' ? (data.error || 'Limit aşıldı.') : 'Teknik bir sorun oluştu. Lütfen daha sonra tekrar deneyin.';
|
| 55 |
throw new Error(userMsg);
|
|
|
|
| 66 |
bubble.innerHTML = '<p></p>';
|
| 67 |
|
| 68 |
wrap.appendChild(bubble);
|
| 69 |
+
const messagesEl = document.getElementById('messages');
|
| 70 |
messagesEl?.appendChild(wrap);
|
| 71 |
|
| 72 |
+
const reader = response.body?.getReader();
|
| 73 |
+
if (!reader) throw new Error('Akış yanıtı desteklenmiyor.');
|
| 74 |
+
|
| 75 |
const decoder = new TextDecoder();
|
| 76 |
let rawText = '';
|
| 77 |
let sseBuffer = '';
|
|
|
|
| 98 |
}
|
| 99 |
|
| 100 |
if (data.congestion) {
|
| 101 |
+
showCongestionNotice(appState);
|
| 102 |
}
|
| 103 |
|
| 104 |
if (data.model_fallback) {
|
| 105 |
+
showModelFallbackNotice(data.model_fallback, appState);
|
| 106 |
}
|
| 107 |
|
| 108 |
if (data.done && data.question_index) {
|
| 109 |
+
appState.lastQuestionIndex = data.question_index;
|
| 110 |
+
wrap.appendChild(createFeedbackBar(appState.lastQuestionIndex));
|
|
|
|
| 111 |
}
|
| 112 |
} catch {
|
| 113 |
// Ignore incomplete or malformed SSE fragments.
|
|
|
|
| 134 |
|
| 135 |
if (!sseHadError) {
|
| 136 |
await loadAuthStatus();
|
|
|
|
| 137 |
updateQuotaDisplay();
|
| 138 |
}
|
| 139 |
} catch (err) {
|
|
|
|
| 142 |
appendMessage('bot', errorText);
|
| 143 |
}
|
| 144 |
|
| 145 |
+
appState.isStreaming = false;
|
| 146 |
sendBtn.disabled = false;
|
| 147 |
inputEl?.focus();
|
| 148 |
scrollToBottom();
|
|
|
|
| 188 |
// Continue silently even if the reset request fails.
|
| 189 |
}
|
| 190 |
|
| 191 |
+
const messagesEl = document.getElementById('messages');
|
| 192 |
if (messagesEl) {
|
| 193 |
messagesEl.innerHTML = welcomeMarkup(
|
| 194 |
'Sohbet sıfırlandı',
|
web/js/entry-gate.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const ENTRY_ACCEPTED_KEY = 'bal_asistan_entry_accepted_v1';
|
| 2 |
+
|
| 3 |
+
export function initializeEntryGate() {
|
| 4 |
+
const gateTabs = document.querySelectorAll('.gate-tab');
|
| 5 |
+
const gateSections = document.querySelectorAll('.gate-section');
|
| 6 |
+
const gateAgree = document.getElementById('gate-agree');
|
| 7 |
+
const gateContinue = document.getElementById('gate-continue');
|
| 8 |
+
const gateStatus = document.getElementById('gate-status');
|
| 9 |
+
const inputEl = document.getElementById('user-input');
|
| 10 |
+
|
| 11 |
+
const visitedGateTabs = {
|
| 12 |
+
terms: true,
|
| 13 |
+
about: false,
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
function openGateTab(tabName) {
|
| 17 |
+
visitedGateTabs[tabName] = true;
|
| 18 |
+
|
| 19 |
+
gateTabs.forEach((tab) => {
|
| 20 |
+
const isActive = tab.dataset.tab === tabName;
|
| 21 |
+
tab.classList.toggle('active', isActive);
|
| 22 |
+
tab.classList.toggle('visited', visitedGateTabs[tab.dataset.tab]);
|
| 23 |
+
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
gateSections.forEach((section) => {
|
| 27 |
+
section.classList.toggle('active', section.id === `panel-${tabName}`);
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
updateGateContinueState();
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function updateGateContinueState() {
|
| 34 |
+
if (!gateAgree || !gateContinue || !gateStatus) return;
|
| 35 |
+
|
| 36 |
+
const canContinue = gateAgree.checked;
|
| 37 |
+
gateContinue.disabled = !canContinue;
|
| 38 |
+
gateStatus.textContent = canContinue
|
| 39 |
+
? 'Hazır. Devam ederek BAL Asistanı açabilirsin.'
|
| 40 |
+
: 'Devam etmek için onay kutusunu işaretle.';
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function enterChat() {
|
| 44 |
+
document.body.classList.remove('gate-active');
|
| 45 |
+
inputEl?.focus();
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
localStorage.removeItem(ENTRY_ACCEPTED_KEY);
|
| 49 |
+
|
| 50 |
+
gateTabs.forEach((tab) => {
|
| 51 |
+
tab.addEventListener('click', () => openGateTab(tab.dataset.tab));
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
gateAgree?.addEventListener('change', updateGateContinueState);
|
| 55 |
+
gateContinue?.addEventListener('click', () => {
|
| 56 |
+
if (!gateContinue.disabled) enterChat();
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
updateGateContinueState();
|
| 60 |
+
}
|
web/js/feedback.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { API_BASE } from './api.js';
|
| 2 |
+
|
| 3 |
+
export function sendFeedback(questionIndex, type) {
|
| 4 |
+
const btns = document.querySelectorAll(`[data-qidx="${questionIndex}"] .fb-btn`);
|
| 5 |
+
btns.forEach((button) => {
|
| 6 |
+
const feedbackType = button.dataset.fbType;
|
| 7 |
+
button.classList.toggle('fb-active', feedbackType === type);
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
fetch(`${API_BASE}/chat/feedback`, {
|
| 11 |
+
method: 'POST',
|
| 12 |
+
credentials: 'same-origin',
|
| 13 |
+
headers: { 'Content-Type': 'application/json' },
|
| 14 |
+
body: JSON.stringify({ question_index: questionIndex, feedback: type }),
|
| 15 |
+
}).catch(() => {});
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function toggleFeedbackBox(questionIndex) {
|
| 19 |
+
const box = document.querySelector(`[data-qidx="${questionIndex}"] .fb-text-box`);
|
| 20 |
+
if (!box) return;
|
| 21 |
+
|
| 22 |
+
const isVisible = box.style.display === 'flex';
|
| 23 |
+
box.style.display = isVisible ? 'none' : 'flex';
|
| 24 |
+
|
| 25 |
+
if (!isVisible) {
|
| 26 |
+
const textarea = box.querySelector('textarea');
|
| 27 |
+
if (textarea) {
|
| 28 |
+
textarea.value = '';
|
| 29 |
+
textarea.focus();
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function submitFeedbackText(questionIndex) {
|
| 35 |
+
const box = document.querySelector(`[data-qidx="${questionIndex}"] .fb-text-box`);
|
| 36 |
+
if (!box) return;
|
| 37 |
+
|
| 38 |
+
const textarea = box.querySelector('textarea');
|
| 39 |
+
const text = textarea ? textarea.value.trim() : '';
|
| 40 |
+
if (!text) {
|
| 41 |
+
toggleFeedbackBox(questionIndex);
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
fetch(`${API_BASE}/chat/feedback`, {
|
| 46 |
+
method: 'POST',
|
| 47 |
+
credentials: 'same-origin',
|
| 48 |
+
headers: { 'Content-Type': 'application/json' },
|
| 49 |
+
body: JSON.stringify({ question_index: questionIndex, feedback_text: text }),
|
| 50 |
+
}).catch(() => {});
|
| 51 |
+
|
| 52 |
+
box.style.display = 'none';
|
| 53 |
+
|
| 54 |
+
const bar = box.closest('.fb-bar');
|
| 55 |
+
if (bar) {
|
| 56 |
+
const existing = bar.querySelector('.fb-thanks');
|
| 57 |
+
if (existing) existing.remove();
|
| 58 |
+
|
| 59 |
+
const thanks = document.createElement('div');
|
| 60 |
+
thanks.className = 'fb-thanks';
|
| 61 |
+
thanks.textContent = 'Geri bildiriminiz için teşekkür ederiz.';
|
| 62 |
+
bar.appendChild(thanks);
|
| 63 |
+
setTimeout(() => {
|
| 64 |
+
thanks.remove();
|
| 65 |
+
}, 2500);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export function createFeedbackBar(questionIndex) {
|
| 70 |
+
const bar = document.createElement('div');
|
| 71 |
+
bar.className = 'fb-bar';
|
| 72 |
+
bar.dataset.qidx = questionIndex;
|
| 73 |
+
|
| 74 |
+
const likeBtn = document.createElement('button');
|
| 75 |
+
likeBtn.className = 'fb-btn';
|
| 76 |
+
likeBtn.dataset.fbType = 'like';
|
| 77 |
+
likeBtn.title = 'Yararlı';
|
| 78 |
+
likeBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>`;
|
| 79 |
+
likeBtn.addEventListener('click', () => sendFeedback(questionIndex, 'like'));
|
| 80 |
+
|
| 81 |
+
const dislikeBtn = document.createElement('button');
|
| 82 |
+
dislikeBtn.className = 'fb-btn';
|
| 83 |
+
dislikeBtn.dataset.fbType = 'dislike';
|
| 84 |
+
dislikeBtn.title = 'Yanlış veya yetersiz';
|
| 85 |
+
dislikeBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V7H7.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3"/></svg>`;
|
| 86 |
+
dislikeBtn.addEventListener('click', () => sendFeedback(questionIndex, 'dislike'));
|
| 87 |
+
|
| 88 |
+
const chatBtn = document.createElement('button');
|
| 89 |
+
chatBtn.className = 'fb-btn';
|
| 90 |
+
chatBtn.title = 'Geri bildirim yaz';
|
| 91 |
+
chatBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
|
| 92 |
+
chatBtn.addEventListener('click', () => toggleFeedbackBox(questionIndex));
|
| 93 |
+
|
| 94 |
+
bar.appendChild(likeBtn);
|
| 95 |
+
bar.appendChild(dislikeBtn);
|
| 96 |
+
bar.appendChild(chatBtn);
|
| 97 |
+
|
| 98 |
+
const textBox = document.createElement('div');
|
| 99 |
+
textBox.className = 'fb-text-box';
|
| 100 |
+
textBox.style.display = 'none';
|
| 101 |
+
textBox.innerHTML = `
|
| 102 |
+
<textarea class="fb-textarea" placeholder="Geri bildiriminizi yazın..." rows="2" maxlength="500"></textarea>
|
| 103 |
+
<div class="fb-text-actions">
|
| 104 |
+
<button class="fb-text-cancel" type="button">İptal</button>
|
| 105 |
+
<button class="fb-text-send" type="button">Gönder</button>
|
| 106 |
+
</div>
|
| 107 |
+
`;
|
| 108 |
+
|
| 109 |
+
textBox.querySelector('.fb-text-send')?.addEventListener('click', () => submitFeedbackText(questionIndex));
|
| 110 |
+
textBox.querySelector('.fb-text-cancel')?.addEventListener('click', () => {
|
| 111 |
+
textBox.style.display = 'none';
|
| 112 |
+
});
|
| 113 |
+
textBox.querySelector('textarea')?.addEventListener('keydown', (event) => {
|
| 114 |
+
if (event.key === 'Enter' && !event.shiftKey) {
|
| 115 |
+
event.preventDefault();
|
| 116 |
+
submitFeedbackText(questionIndex);
|
| 117 |
+
}
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
bar.appendChild(textBox);
|
| 121 |
+
return bar;
|
| 122 |
+
}
|
web/js/main.js
CHANGED
|
@@ -1,68 +1,9 @@
|
|
| 1 |
import { checkHealth, loadAuthStatus } from './api.js';
|
| 2 |
import { clearChat, initializeChatApp, sendMessage, sendSuggestion } from './chat.js';
|
| 3 |
-
|
| 4 |
-
const ENTRY_ACCEPTED_KEY = 'bal_asistan_entry_accepted_v1';
|
| 5 |
-
const gateTabs = document.querySelectorAll('.gate-tab');
|
| 6 |
-
const gateSections = document.querySelectorAll('.gate-section');
|
| 7 |
-
const gateAgree = document.getElementById('gate-agree');
|
| 8 |
-
const gateContinue = document.getElementById('gate-continue');
|
| 9 |
-
const gateStatus = document.getElementById('gate-status');
|
| 10 |
-
const inputEl = document.getElementById('user-input');
|
| 11 |
-
|
| 12 |
-
const visitedGateTabs = {
|
| 13 |
-
terms: true,
|
| 14 |
-
about: false,
|
| 15 |
-
};
|
| 16 |
-
|
| 17 |
-
function openGateTab(tabName) {
|
| 18 |
-
visitedGateTabs[tabName] = true;
|
| 19 |
-
|
| 20 |
-
gateTabs.forEach((tab) => {
|
| 21 |
-
const isActive = tab.dataset.tab === tabName;
|
| 22 |
-
tab.classList.toggle('active', isActive);
|
| 23 |
-
tab.classList.toggle('visited', visitedGateTabs[tab.dataset.tab]);
|
| 24 |
-
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
| 25 |
-
});
|
| 26 |
-
|
| 27 |
-
gateSections.forEach((section) => {
|
| 28 |
-
section.classList.toggle('active', section.id === `panel-${tabName}`);
|
| 29 |
-
});
|
| 30 |
-
|
| 31 |
-
updateGateContinueState();
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
function updateGateContinueState() {
|
| 35 |
-
if (!gateAgree || !gateContinue || !gateStatus) return;
|
| 36 |
-
|
| 37 |
-
const canContinue = gateAgree.checked;
|
| 38 |
-
gateContinue.disabled = !canContinue;
|
| 39 |
-
gateStatus.textContent = canContinue
|
| 40 |
-
? 'Hazır. Devam ederek BAL Asistanı açabilirsin.'
|
| 41 |
-
: 'Devam etmek için onay kutusunu işaretle.';
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
function enterChat() {
|
| 45 |
-
document.body.classList.remove('gate-active');
|
| 46 |
-
inputEl?.focus();
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
function initEntryGate() {
|
| 50 |
-
localStorage.removeItem(ENTRY_ACCEPTED_KEY);
|
| 51 |
-
|
| 52 |
-
gateTabs.forEach((tab) => {
|
| 53 |
-
tab.addEventListener('click', () => openGateTab(tab.dataset.tab));
|
| 54 |
-
});
|
| 55 |
-
|
| 56 |
-
gateAgree?.addEventListener('change', updateGateContinueState);
|
| 57 |
-
gateContinue?.addEventListener('click', () => {
|
| 58 |
-
if (!gateContinue.disabled) enterChat();
|
| 59 |
-
});
|
| 60 |
-
|
| 61 |
-
updateGateContinueState();
|
| 62 |
-
}
|
| 63 |
|
| 64 |
function bootstrap() {
|
| 65 |
-
|
| 66 |
initializeChatApp();
|
| 67 |
|
| 68 |
window.sendSuggestion = sendSuggestion;
|
|
|
|
| 1 |
import { checkHealth, loadAuthStatus } from './api.js';
|
| 2 |
import { clearChat, initializeChatApp, sendMessage, sendSuggestion } from './chat.js';
|
| 3 |
+
import { initializeEntryGate } from './entry-gate.js';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
function bootstrap() {
|
| 6 |
+
initializeEntryGate();
|
| 7 |
initializeChatApp();
|
| 8 |
|
| 9 |
window.sendSuggestion = sendSuggestion;
|
web/js/state.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const SESSION_ID = 'session_' + Math.random().toString(36).slice(2, 9);
|
| 2 |
+
|
| 3 |
+
export const quotaInfo = {
|
| 4 |
+
daily_used: 0,
|
| 5 |
+
minute_used: 0,
|
| 6 |
+
daily_limit: 40,
|
| 7 |
+
minute_limit: 5,
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export const appState = {
|
| 11 |
+
isStreaming: false,
|
| 12 |
+
lastQuestionIndex: null,
|
| 13 |
+
limitNoticeVisible: false,
|
| 14 |
+
congestionNoticeVisible: false,
|
| 15 |
+
notifiedFallbackModels: new Set(),
|
| 16 |
+
};
|
web/js/ui.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { formatText } from './utils.js';
|
| 2 |
+
|
| 3 |
+
const messagesEl = document.getElementById('messages');
|
| 4 |
+
const noticeLayer = document.getElementById('notice-layer');
|
| 5 |
+
|
| 6 |
+
export function scrollToBottom() {
|
| 7 |
+
if (messagesEl) {
|
| 8 |
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function appendMessage(role, text) {
|
| 13 |
+
document.getElementById('welcome-msg')?.remove();
|
| 14 |
+
|
| 15 |
+
const wrap = document.createElement('div');
|
| 16 |
+
wrap.className = `msg-wrap ${role}`;
|
| 17 |
+
|
| 18 |
+
const bubble = document.createElement('div');
|
| 19 |
+
bubble.className = 'bubble';
|
| 20 |
+
bubble.innerHTML = formatText(text);
|
| 21 |
+
|
| 22 |
+
wrap.appendChild(bubble);
|
| 23 |
+
messagesEl?.appendChild(wrap);
|
| 24 |
+
scrollToBottom();
|
| 25 |
+
return bubble;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function appendTypingIndicator() {
|
| 29 |
+
document.getElementById('welcome-msg')?.remove();
|
| 30 |
+
|
| 31 |
+
const wrap = document.createElement('div');
|
| 32 |
+
wrap.className = 'msg-wrap bot';
|
| 33 |
+
wrap.id = 'typing-wrap';
|
| 34 |
+
|
| 35 |
+
const indicator = document.createElement('div');
|
| 36 |
+
indicator.className = 'typing-indicator';
|
| 37 |
+
indicator.innerHTML = '<span></span><span></span><span></span>';
|
| 38 |
+
|
| 39 |
+
wrap.appendChild(indicator);
|
| 40 |
+
messagesEl?.appendChild(wrap);
|
| 41 |
+
scrollToBottom();
|
| 42 |
+
return wrap;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export function showLimitNotice(remaining = null, stateRef) {
|
| 46 |
+
if (!noticeLayer || stateRef.limitNoticeVisible) return;
|
| 47 |
+
|
| 48 |
+
stateRef.limitNoticeVisible = true;
|
| 49 |
+
noticeLayer.innerHTML = '';
|
| 50 |
+
|
| 51 |
+
const pill = document.createElement('div');
|
| 52 |
+
pill.className = 'notice-pill';
|
| 53 |
+
const countText = Number.isFinite(remaining) ? ` ${remaining} günlük hakkın kaldı.` : '';
|
| 54 |
+
pill.innerHTML = `
|
| 55 |
+
<span>Hakkın az kaldı.${countText}</span>
|
| 56 |
+
<button class="notice-close" type="button" aria-label="Kapat">×</button>
|
| 57 |
+
`;
|
| 58 |
+
|
| 59 |
+
pill.querySelector('.notice-close')?.addEventListener('click', () => {
|
| 60 |
+
stateRef.limitNoticeVisible = false;
|
| 61 |
+
pill.remove();
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
noticeLayer.appendChild(pill);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export function showCongestionNotice(stateRef) {
|
| 68 |
+
if (!noticeLayer || stateRef.congestionNoticeVisible) return;
|
| 69 |
+
|
| 70 |
+
stateRef.congestionNoticeVisible = true;
|
| 71 |
+
const pill = document.createElement('div');
|
| 72 |
+
pill.className = 'notice-pill';
|
| 73 |
+
pill.innerHTML = `
|
| 74 |
+
<span>⚠️ Şu anda yoğunluk var, yanıtlar normalden geç gelebilir.</span>
|
| 75 |
+
<button class="notice-close" type="button" aria-label="Kapat">×</button>
|
| 76 |
+
`;
|
| 77 |
+
|
| 78 |
+
pill.querySelector('.notice-close')?.addEventListener('click', () => {
|
| 79 |
+
stateRef.congestionNoticeVisible = false;
|
| 80 |
+
pill.remove();
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
noticeLayer.appendChild(pill);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export function hideCongestionNotice(stateRef) {
|
| 87 |
+
if (!noticeLayer || !stateRef.congestionNoticeVisible) return;
|
| 88 |
+
|
| 89 |
+
stateRef.congestionNoticeVisible = false;
|
| 90 |
+
const pills = noticeLayer.querySelectorAll('.notice-pill');
|
| 91 |
+
pills.forEach((pill) => {
|
| 92 |
+
if (pill.textContent.includes('yoğunluk')) pill.remove();
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
export function showModelFallbackNotice(notice, stateRef) {
|
| 97 |
+
if (!noticeLayer) return;
|
| 98 |
+
|
| 99 |
+
const targetModel = notice?.to_model || 'unknown';
|
| 100 |
+
if (stateRef.notifiedFallbackModels.has(targetModel)) return;
|
| 101 |
+
stateRef.notifiedFallbackModels.add(targetModel);
|
| 102 |
+
|
| 103 |
+
const pill = document.createElement('div');
|
| 104 |
+
pill.className = 'notice-pill';
|
| 105 |
+
pill.innerHTML = `
|
| 106 |
+
<span>Yoğunluk nedeniyle farklı bir model kullanılıyor.</span>
|
| 107 |
+
<button class="notice-close" type="button" aria-label="Kapat">×</button>
|
| 108 |
+
`;
|
| 109 |
+
|
| 110 |
+
noticeLayer.appendChild(pill);
|
| 111 |
+
}
|
web/js/utils.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function formatText(text) {
|
| 2 |
+
return text
|
| 3 |
+
.replace(/&/g, '&')
|
| 4 |
+
.replace(/</g, '<')
|
| 5 |
+
.replace(/>/g, '>')
|
| 6 |
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
| 7 |
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
| 8 |
+
.replace(/`(.+?)`/g, '<code>$1</code>')
|
| 9 |
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
|
| 10 |
+
.replace(/^#{1,3}\s+(.+)$/gm, '<strong>$1</strong>')
|
| 11 |
+
.replace(/^[-•]\s+(.+)$/gm, '• $1')
|
| 12 |
+
.replace(/\n\n/g, '</p><p>')
|
| 13 |
+
.replace(/\n/g, '<br>')
|
| 14 |
+
.replace(/^/, '<p>')
|
| 15 |
+
.replace(/$/, '</p>');
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function stripReasoningText(text) {
|
| 19 |
+
return text
|
| 20 |
+
.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '')
|
| 21 |
+
.replace(/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi, '')
|
| 22 |
+
.replace(/<think\b[^>]*>[\s\S]*$/gi, '')
|
| 23 |
+
.replace(/<thinking\b[^>]*>[\s\S]*$/gi, '')
|
| 24 |
+
.trim();
|
| 25 |
+
}
|