openajaj / static /index.html
Jindrich3's picture
Super-squash branch 'main' using huggingface_hub
00c2992
Raw
History Blame Contribute Delete
94.8 kB
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="icon" href="/static/favicon.ico">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<title>OpenAjaj</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-main: #1a1208;
--bg-sidebar: #110d05;
--bg-user: #1a1208;
--bg-assistant: #231908;
--text: #f0e0b0;
--text-muted: #9a8455;
--border: #3d2e10;
--accent: #c8a030;
--input-bg: #201408;
}
:root.light {
--bg-main: #fdf7ee;
--bg-sidebar: #f5edd8;
--bg-user: #fdf7ee;
--bg-assistant: #f0e6cc;
--text: #2a1e08;
--text-muted: #7a6040;
--border: #d4b870;
--accent: #a07820;
--input-bg: #f8f0dc;
}
:root.pink {
--bg-main: #fff0f5;
--bg-sidebar: #ffe4ec;
--bg-user: #fff0f5;
--bg-assistant: #ffe8ef;
--text: #4a0e2a;
--text-muted: #9e4b6e;
--border: #f5c2d4;
--accent: #e91e8c;
--input-bg: #fff5f8;
}
body {
font-family: 'Söhne', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-main);
color: var(--text);
height: 100vh;
display: flex;
}
/* Sidebar */
.sidebar {
width: 310px;
background: var(--bg-sidebar);
overflow-y: auto;
overscroll-behavior: contain;
padding: 8px;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.2s;
}
.sidebar-header:hover { background: rgba(128,128,128,0.1); }
.sidebar-brand {
padding: 20px 12px;
text-align: center;
}
.sidebar-brand .logo {
width: 224px;
height: 150px;
margin: 0 auto 8px;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-brand .logo img {
width: 224px;
height: 150px;
object-fit: contain;
}
.sidebar-brand h2 {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.sidebar-brand p {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.sidebar-footer {
margin-top: auto;
padding: 12px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
text-align: center;
}
/* Main area */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
/* Header */
.header {
padding: 12px 20px;
border-bottom: 1px solid var(--border);
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.top-bench-panel {
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border);
padding: 8px 20px;
font-size: 11px;
color: var(--text);
max-height: 300px;
overflow-y: auto;
}
.model-select {
background: var(--input-bg);
border: 1px solid var(--border);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
color: var(--text);
cursor: pointer;
outline: none;
max-width: 300px;
}
.model-select:focus { border-color: var(--accent); }
.free-tag {
background: #c8a030;
color: #000;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
margin-left: 4px;
}
.header .model-badge {
background: var(--input-bg);
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 11px;
font-weight: 400;
font-family: inherit;
color: var(--text-muted);
cursor: pointer;
max-width: 40vw;
-webkit-appearance: none;
}
/* Messages */
.messages {
flex: 1;
overflow-y: auto;
padding: 0;
}
.message {
padding: 24px 0;
display: flex;
justify-content: center;
}
.message.assistant { background: var(--bg-assistant); }
.message-inner {
max-width: 768px;
width: 100%;
padding: 0 24px;
display: flex;
gap: 16px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 4px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
}
.avatar.user-avatar {
background: #3d2e10;
color: #c8a030;
font-weight: 800;
font-size: 11px;
}
.avatar.bot-avatar {
background: transparent;
padding: 2px;
}
.avatar.bot-avatar img {
width: 100%; height: 100%;
object-fit: contain;
border-radius: 4px;
}
.message-content {
flex: 1;
line-height: 1.7;
font-size: 15px;
min-width: 0;
}
.message-content p { margin-bottom: 12px; }
.message-content p:last-child { margin-bottom: 0; }
.msg-model-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
}
.msg-model-bar .model-stats {
font-size: 10px;
color: var(--text);
opacity: 0.6;
}
.msg-model-bar select {
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
font-family: inherit;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
outline: none;
}
.msg-model-bar .action-btn {
cursor: pointer;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text-muted);
}
.msg-model-bar .action-btn:hover { opacity: 1; background: rgba(128,128,128,0.15); }
.tts-player {
display: flex; align-items: center; gap: 6px;
padding: 4px 2px 2px 2px; margin-top: 3px;
}
.tts-player-btn {
cursor: pointer; font-size: 20px; line-height: 1;
background: none; border: none; padding: 0 2px;
color: var(--text-muted); opacity: 0.85; transition: opacity 0.15s, color 0.15s;
flex-shrink: 0;
}
.tts-player-btn:hover { opacity: 1; }
.tts-player-btn.loading { opacity: 0.45; cursor: default; }
.tts-player-btn.active { color: var(--accent); opacity: 1; }
.tts-progress-wrap {
flex: 1; height: 5px; background: var(--border); border-radius: 3px;
cursor: pointer; position: relative; overflow: hidden;
}
.tts-progress-fill {
height: 100%; border-radius: 3px; background: var(--accent);
width: 0%; transition: width 0.15s linear; pointer-events: none;
}
.tts-player.buffering .tts-progress-fill {
width: 40% !important; transition: none;
animation: tts-buf 1s ease-in-out infinite;
}
@keyframes tts-buf { 0%,100%{opacity:0.15} 50%{opacity:0.5} }
.tts-time {
font-size: 10px; color: var(--text-muted); opacity: 0.65; flex-shrink: 0;
min-width: 40px; text-align: right; font-variant-numeric: tabular-nums;
}
.tts-voice-sel {
cursor: pointer; padding: 2px 4px; border-radius: 3px;
font-size: 11px; opacity: 0.6; border: 1px solid var(--border);
background: var(--input-bg); color: var(--text-muted);
flex-shrink: 0;
}
.tts-voice-sel:hover { opacity: 1; }
.msg-model-bar .retry-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.thinking-hint {
color: var(--text-muted);
font-style: italic;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.necylink {
color: var(--accent);
text-decoration: underline;
text-decoration-style: dotted;
cursor: pointer;
}
.necylink:hover { text-decoration-style: solid; }
.necylink-ext {
color: var(--accent);
text-decoration: none;
opacity: 0.8;
cursor: pointer;
margin-left: 1px;
margin-right: 2px;
font-weight: 700;
}
.necylink-ext sup {
font-size: 0.65em;
vertical-align: super;
line-height: 0;
}
.necylink-ext:hover { opacity: 1; }
/* Necyklopedia link popup */
.necyk-popup {
position: fixed;
z-index: 9999;
background: var(--bg-sidebar);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 10px 13px;
max-width: 260px;
box-shadow: 0 4px 18px rgba(0,0,0,0.5);
font-size: 12px;
color: var(--text);
line-height: 1.5;
animation: necyk-in 0.12s ease;
}
@keyframes necyk-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.necyk-popup-btns {
display: flex;
gap: 7px;
margin-top: 8px;
}
.necyk-popup-btns button {
flex: 1;
padding: 4px 0;
border-radius: 5px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
font-size: 11px;
cursor: pointer;
}
.necyk-popup-btns button.primary {
background: var(--accent);
color: #1a1208;
border-color: var(--accent);
font-weight: 600;
}
.necyk-popup-btns button:hover { opacity: 0.8; }
/* Welcome screen */
.welcome {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 16px 40px;
}
.welcome-logo {
margin-bottom: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-logo img {
width: 400px;
height: 267px;
object-fit: contain;
}
.welcome h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 4px;
}
.welcome p {
color: var(--text-muted);
font-size: 16px;
margin-bottom: 16px;
}
.examples, .examples-inner {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
max-width: 600px;
width: 100%;
}
.example {
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
cursor: pointer;
font-size: 13px;
color: var(--text);
transition: background 0.2s;
text-align: left;
}
.example:hover { background: rgba(128,128,128,0.1); }
/* Input area */
.input-area {
padding: 16px 24px calc(12px + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
align-items: center;
}
.input-wrapper {
max-width: 768px;
width: 100%;
display: flex;
align-items: center;
gap: 6px;
}
.input-box {
flex: 1;
min-width: 0;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
color: var(--text);
font-size: 15px;
font-family: inherit;
resize: none;
outline: none;
max-height: 200px;
line-height: 1.5;
}
.input-box::placeholder { color: var(--text-muted); }
.input-box:focus { border-color: var(--accent); }
.mic-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
transition: all 0.15s;
}
.mic-btn:hover { color: var(--text); border-color: var(--text-muted); }
.mic-btn.recording {
background: #c0392b;
border-color: #c0392b;
color: #fff;
animation: mic-pulse 1s ease-in-out infinite;
}
@keyframes mic-pulse { 0%,100%{opacity:1} 50%{opacity:0.55} }
.mic-btn.mic-unavailable {
opacity: 0.35;
cursor: not-allowed;
}
.mic-btn.mic-unavailable::after {
content: '';
position: absolute;
inset: -2px;
background: linear-gradient(135deg, transparent 45%, var(--text-muted) 45%, var(--text-muted) 55%, transparent 55%);
border-radius: 6px;
pointer-events: none;
}
.input-wrapper.mic-connecting {
box-shadow: 0 0 0 2px rgba(192,57,43,0.4);
border-radius: 12px;
transition: box-shadow 0.2s;
}
.input-wrapper.mic-on {
box-shadow: 0 0 0 3px rgba(192,57,43,0.75), 0 0 14px rgba(192,57,43,0.35);
border-radius: 12px;
animation: mic-ring-pulse 1.2s ease-in-out infinite;
}
@keyframes mic-ring-pulse {
0%,100% { box-shadow: 0 0 0 3px rgba(192,57,43,0.75), 0 0 14px rgba(192,57,43,0.35); }
50% { box-shadow: 0 0 0 3px rgba(192,57,43,0.4), 0 0 24px rgba(192,57,43,0.55); }
}
/* Voice mode button in header */
.voice-mode-btn {
margin-left: auto;
background: var(--accent);
border: none;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #000;
transition: opacity 0.2s, box-shadow 0.2s;
white-space: nowrap;
opacity: 0.7;
}
.voice-mode-btn:hover { opacity: 1; }
.voice-mode-btn.active {
opacity: 1;
box-shadow: 0 0 0 2px var(--bg-main), 0 0 0 4px var(--accent);
}
/* Voice mode overlay */
.voice-mode-overlay {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 16px calc(16px + env(safe-area-inset-bottom, 0px));
gap: 10px;
max-width: 768px;
width: 100%;
}
.voice-mode-overlay .vm-row {
display: flex; align-items: center; gap: 12px; width: 100%; justify-content: center;
}
.voice-mode-status-text {
font-size: 12px;
font-weight: 600;
color: var(--text);
display: none;
}
.voice-mode-main-btn {
width: 56px; height: 56px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--input-bg);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
.voice-mode-main-btn .vm-btn-logo {
width: 44px; height: 44px; object-fit: contain; border-radius: 50%;
}
.voice-mode-main-btn.listening .vm-btn-logo {
animation: vm-listen-wobble 0.8s ease-in-out infinite;
}
.voice-mode-main-btn.speaking .vm-btn-logo {
animation: vm-speak-scale 1s ease-in-out infinite;
}
@keyframes vm-listen-wobble {
0%,100% { transform: rotate(0deg) scale(1); }
25% { transform: rotate(-3deg) scale(0.97); }
50% { transform: rotate(0deg) scale(1.03); }
75% { transform: rotate(3deg) scale(0.97); }
}
@keyframes vm-speak-scale {
0%,100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
.voice-mode-main-btn .vm-btn-mic {
position: absolute; bottom: 2px; right: 2px;
width: 18px; height: 18px; background: var(--accent); border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 10px; line-height: 1;
}
.voice-mode-main-btn .vm-btn-emoji { font-size: 24px; line-height: 1; }
.voice-mode-main-btn .vm-btn-label { display: none; }
.voice-mode-main-btn:hover:not(.thinking) { border-color: var(--accent); }
.voice-mode-main-btn.idle { border-color: var(--border); }
.voice-mode-main-btn.listening {
border-color: #c0392b;
background: rgba(192,57,43,0.08);
animation: vm-btn-pulse 1.2s ease-in-out infinite;
}
.voice-mode-main-btn.thinking {
opacity: 0.55; cursor: default;
animation: vm-think-spin 2s linear infinite;
}
.voice-mode-main-btn.speaking {
border-color: #27ae60;
background: rgba(39,174,96,0.08);
animation: vm-btn-speak-pulse 1.2s ease-in-out infinite;
}
@keyframes vm-btn-speak-pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(39,174,96,0.4); }
50% { box-shadow: 0 0 0 18px rgba(39,174,96,0); }
}
@keyframes vm-btn-pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(192,57,43,0.35); }
50% { box-shadow: 0 0 0 12px rgba(192,57,43,0); }
}
@keyframes vm-think-spin {
0% { box-shadow: 0 0 0 2px rgba(128,128,128,0.1); }
50% { box-shadow: 0 0 0 2px rgba(128,128,128,0.4); }
100% { box-shadow: 0 0 0 2px rgba(128,128,128,0.1); }
}
.vm-necyk-logo {
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
animation: necyk-wobble 0.7s ease-in-out infinite;
}
.vm-necyk-logo img {
width: 38px; height: 38px;
object-fit: contain; border-radius: 10px;
}
@keyframes necyk-wobble {
0% { transform: rotate(-8deg) scale(0.92); }
25% { transform: rotate(6deg) scale(1.05); }
50% { transform: rotate(-5deg) scale(0.96); }
75% { transform: rotate(7deg) scale(1.03); }
100% { transform: rotate(-8deg) scale(0.92); }
}
.voice-mode-transcript {
font-size: 14px; color: var(--text); font-style: italic;
width: 100%; text-align: center;
min-height: 40px; line-height: 1.4;
padding: 4px 8px;
}
.voice-mode-exit-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 11px;
font-family: inherit;
transition: all 0.15s;
flex-shrink: 0;
}
.voice-mode-exit-btn:hover { border-color: var(--text-muted); color: var(--text); }
.send-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
background: var(--accent);
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
}
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.send-btn svg {
width: 16px;
height: 16px;
fill: var(--accent);
}
.disclaimer {
text-align: center;
font-size: 11px;
color: var(--text-muted);
margin-top: 8px;
cursor: pointer;
}
.disclaimer.expanded .disclaimer-short { display: none; }
.disclaimer.expanded .disclaimer-full { display: inline !important; }
/* Typing indicator */
.typing-dot {
display: inline-block;
width: 14px;
height: 14px;
background: var(--accent);
border-radius: 50%;
animation: typing 1.4s infinite;
margin-right: 6px;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; margin-right: 0; }
@keyframes typing {
0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
30% { opacity: 1; transform: scale(1); }
}
/* Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.2); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(128,128,128,0.4); }
/* Theme toggle */
.theme-toggle {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
transition: background 0.2s;
color: var(--text);
}
.theme-toggle:hover { background: rgba(128,128,128,0.1); }
.theme-icon { font-size: 16px; }
/* Chat history */
.history-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.history-item {
padding: 8px 12px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
margin: 2px 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.15s;
}
.history-item:hover { background: rgba(128,128,128,0.1); }
.history-item.active { background: rgba(128,128,128,0.15); color: var(--text); }
.history-item .delete-btn {
float: right;
opacity: 0;
cursor: pointer;
padding: 0 4px;
}
.history-item:hover .delete-btn { opacity: 0.5; }
.history-item:hover .delete-btn:hover { opacity: 1; }
/* Mobile */
/* Mobile hamburger */
.hamburger {
display: none;
cursor: pointer;
font-size: 22px;
padding: 4px 8px;
border-radius: 6px;
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
}
.sidebar-close { display: none; }
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 99;
}
.sidebar-overlay.open { display: block; }
@media (max-width: 768px) {
.sidebar {
display: none;
position: fixed;
left: 0; top: 0; bottom: 0;
width: 75vw;
max-width: 260px;
z-index: 100;
box-shadow: 4px 0 20px rgba(0,0,0,0.3);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.sidebar.open { display: flex; }
.sidebar-footer { margin-top: 12px; }
.sidebar-close {
display: block;
text-align: right;
padding: 8px 12px 0;
font-size: 24px;
cursor: pointer;
color: var(--text-muted);
}
.hamburger { display: block; }
.examples { grid-template-columns: 1fr; }
.message-inner { flex-direction: column; align-items: flex-start; gap: 4px; }
.avatar { width: 28px; height: 28px; font-size: 9px; }
.message-content { width: 100%; }
.input-area { padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px)); }
.vm-label { display: none; }
.voice-mode-btn { padding: 4px 8px; font-size: 14px; min-width: 0; }
.sidebar-brand .logo,
.sidebar-brand .logo img { width: 112px; height: 75px; }
.welcome-logo img { width: 260px; height: 173px; }
}
</style>
</head>
<body>
<div class="sidebar">
<div class="sidebar-close" onclick="toggleSidebar()">&times;</div>
<div class="sidebar-header" onclick="newChat()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1v6H2v2h6v6h2V9h6V7H10V1z"/></svg>
Nový chat
</div>
<div class="sidebar-brand">
<div class="logo"><img src="/static/logo.png" alt="OpenAjaj"></div>
<h2>OpenAjaj</h2>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">Poháněno <a href="https://necyklopedie.org" style="color:var(--accent);text-decoration:none;cursor:pointer" onclick="_necykPopup(event,'https://necyklopedie.org')">Necyklopedií</a></div>
</div>
<div class="history-list" id="historyList"></div>
<div class="sidebar-footer">
<div class="theme-toggle" onclick="randomArticle()" id="randomBtn">
<span class="theme-icon">&#127922;</span>
<span id="randomLabel">Poselství Jolandy</span>
</div>
<div class="theme-toggle" onclick="toggleSettingsPanel()">
<span class="theme-icon">&#9881;</span>
<span>Nastavení</span>
</div>
<div id="settingsPanel" style="display:none;padding:6px 0">
<div class="theme-toggle" onclick="toggleSettingsPanel()" style="margin-top:2px;opacity:0.5">
<span class="theme-icon">&#10005;</span>
<span>Zavřít nastavení</span>
</div>
<div class="theme-toggle" onclick="toggleTheme();toggleSettingsPanel()" style="margin-top:4px">
<span class="theme-icon" id="themeIcon">&#9790;</span>
<span id="themeLabel">Světlý režim</span>
</div>
<div style="padding:4px 12px 2px;font-size:14px;color:var(--text);font-weight:700;margin-top:6px" id="brainLabel">Zdravý rozum:</div>
<div style="padding:2px 12px">
<select id="settingsModelSelect" onchange="onSettingsModelChange()" style="width:100%;background:var(--input-bg);border:1px solid var(--border);color:var(--text);font-size:13px;font-family:inherit;padding:6px 8px;border-radius:4px;cursor:pointer"></select>
</div>
<div class="theme-toggle" onclick="runBenchmark()" style="margin-top:2px" id="benchBtn">
<span class="theme-icon">&#9889;</span>
<span id="benchLabel">Změřit IQ</span>
</div>
<div id="benchResults" style="display:none;padding:0 12px"></div>
<div class="theme-toggle" onclick="clearAllHistory();toggleSettingsPanel()" style="margin-top:4px">
<span class="theme-icon">&#128465;</span>
<span>Smazat historii</span>
</div>
<div class="theme-toggle" onclick="resetPreferences()" style="margin-top:2px">
<span class="theme-icon">&#128260;</span>
<span>Smazat nastaven&iacute;</span>
</div>
<div class="theme-toggle" onclick="toggleAdvanced()" style="margin-top:6px;font-size:11px;opacity:0.6">
<span class="theme-icon">&#128300;</span>
<span>Experimentální úniky z laboratoře</span>
</div>
<div id="advancedPanel" style="display:none;padding:4px 0">
<div style="padding:2px 12px;font-size:10px;color:var(--text-muted)">Alternativní dimenze:</div>
<div id="personaList"></div>
</div>
</div>
<div style="font-size:10px;color:var(--text-muted);text-align:center;margin-top:6px">openajaj.cz</div>
</div>
</div>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
<div class="main">
<div class="header">
<button class="hamburger" onclick="toggleSidebar()">&#9776;</button>
<span>OpenAjaj</span>
<select class="model-badge" id="modelBadge" onchange="onTopModelChange(this)"></select>
<button class="voice-mode-btn" id="voiceModeBtn" onclick="toggleVoiceMode()" title="Hlasový režim — automatický mikrofon + čtení odpovědí">🎧 <span class="vm-label">Hlasový režim</span></button>
</div>
<div id="topBenchPanel" class="top-bench-panel" style="display:none" onclick="this.style.display='none'"></div>
<div class="messages" id="messages">
<div class="welcome" id="welcome">
<div class="welcome-logo"><img src="/static/logo.png" alt="OpenAjaj"></div>
<h1>OpenAjaj</h1>
<p id="welcomeSubtitle"></p>
<div class="examples" id="examplesInner"></div>
</div>
</div>
<div class="input-area">
<!-- Voice mode overlay — shown instead of normal input -->
<div id="voiceModeOverlay" class="voice-mode-overlay" style="display:none">
<div id="voiceModeTranscript" class="voice-mode-transcript">Klikni a mluv</div>
<div class="vm-row">
<button class="voice-mode-main-btn idle" id="voiceModeMainBtn" onclick="voiceModeMainBtnClick()">
<img class="vm-btn-logo" src="/static/logo.png" alt="">
<span class="vm-btn-mic">🎙</span>
</button>
<button class="voice-mode-exit-btn" onclick="toggleVoiceMode()"></button>
</div>
<div id="voiceModeStatus" class="voice-mode-status-text"></div>
</div>
<div class="input-wrapper">
<button class="mic-btn" id="micBtn" onclick="toggleMic()" title="Hlasový vstup">
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor">
<path d="M5 3a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0V3z"/>
<path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z"/>
</svg>
</button>
<textarea class="input-box" id="input" rows="1"
placeholder="Ptej se..."
onkeydown="handleKey(event)"
oninput="autoResize(this)"></textarea>
<button class="send-btn" id="sendBtn" onclick="sendMessage()">
<svg viewBox="0 0 16 16"><path d="M.5 1.163A1 1 0 0 1 1.97.28l12.868 6.837a1 1 0 0 1 0 1.766L1.969 15.72A1 1 0 0 1 .5 14.836V10.33a1 1 0 0 1 .816-.983L8.5 8 1.316 6.653A1 1 0 0 1 .5 5.67V1.163Z"/></svg>
</button>
</div>
<div class="disclaimer" onclick="this.classList.toggle('expanded')" title="Klikni pro detail">
<span class="disclaimer-short"><strong style="color:var(--accent)">⚠ Varování</strong></span>
<span class="disclaimer-full" style="display:none">
<strong style="color:var(--accent)">Varování:</strong> OpenAjaj může obsahovat politicky nekorektní obsah, sprostá slova a jiné nevhodné formulace a obrázky.<br>
<strong style="color:var(--accent)">!!!Proto by ho nikdo neměl používat!!!</strong>
</span>
</div>
</div>
</div>
<script>
// ── Captcha session (localStorage-based, works in third-party iframes) ──
(function(){
// If redirected from captcha with ?cs=TOKEN, save to localStorage and clean URL
const _urlCs = new URLSearchParams(location.search).get('cs');
if (_urlCs) {
localStorage.setItem('ajaj_v', _urlCs);
history.replaceState(null, '', location.pathname);
}
const _tok = localStorage.getItem('ajaj_v');
if (!_tok) return;
const _origFetch = window.fetch;
window.fetch = function(url, opts) {
if (typeof url === 'string' && url.startsWith('/api/')) {
opts = opts || {};
opts.headers = Object.assign({}, opts.headers, {'X-Ajaj-Session': _tok});
}
return _origFetch.call(this, url, opts).then(r => {
if (r.status === 403) r.clone().json().then(d => {
if (d && d.error === 'captcha_required') {
localStorage.removeItem('ajaj_v');
window.location.href = '/captcha';
}
}).catch(()=>{});
return r;
});
};
})();
// ── Voice mode ─────────────────────────────────────────────────────────
var _voiceMode = false;
function updateVoiceModeUI(state) {
const statusEl = document.getElementById('voiceModeStatus');
const btn = document.getElementById('voiceModeMainBtn');
if (!statusEl || !btn) return;
const logoBtn = '<img class="vm-btn-logo" src="/static/logo.png" alt=""><span class="vm-btn-mic">🎙</span>';
const cfgMap = {
idle: { transcript: 'Klikni a mluv', inner: logoBtn, cls: 'idle' },
listening: { transcript: 'Poslouchám…', inner: logoBtn.replace('🎙','⏹'), cls: 'listening' },
thinking: { transcript: 'Přemýšlím…', inner: '<div class="vm-necyk-logo"><img src="/static/logo.png" alt=""></div>', cls: 'thinking' },
speaking: { transcript: 'Odpovídám…', inner: '<img class="vm-btn-logo" src="/static/logo.png" alt=""><span class="vm-btn-mic" style="background:#27ae60">⏹</span>', cls: 'speaking' },
};
const cfg = cfgMap[state] || cfgMap.idle;
const transcriptEl = document.getElementById('voiceModeTranscript');
if (transcriptEl && !transcriptEl.textContent.startsWith('"')) transcriptEl.textContent = cfg.transcript;
btn.className = `voice-mode-main-btn ${cfg.cls}`;
btn.innerHTML = cfg.inner;
}
function voiceModeMainBtnClick() {
if (isGenerating) return;
if (currentTtsAudio || currentTtsPlayer) {
stopAllTts();
// onended won't fire after manual stop — restart mic directly
if (_voiceMode) {
updateVoiceModeUI('listening');
setTimeout(() => { if (_voiceMode && !_micActive && !isGenerating) toggleMic(); }, 100);
}
return;
}
if (_micActive) {
// Stop recording and send whatever we have
stopMic();
if (inputEl.value.trim()) {
updateVoiceModeUI('thinking');
sendMessage();
} else {
updateVoiceModeUI('idle');
}
return;
}
toggleMic();
}
var _voiceAudioCtx = null; // AudioContext created in user gesture — immune to autoplay policy
function toggleVoiceMode() {
_voiceMode = !_voiceMode;
const inputWrapper = document.querySelector('.input-wrapper');
const overlay = document.getElementById('voiceModeOverlay');
const btn = document.getElementById('voiceModeBtn');
if (_voiceMode) {
// Create AudioContext HERE (user gesture) so voice TTS can play without autoplay restrictions
if (!_voiceAudioCtx || _voiceAudioCtx.state === 'closed') {
_voiceAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
_voiceAudioCtx.resume().catch(() => {});
btn.classList.add('active');
if (inputWrapper) inputWrapper.style.display = 'none';
if (overlay) overlay.style.display = 'flex';
updateVoiceModeUI('idle');
document.getElementById('voiceModeTranscript').textContent = '';
if (!isGenerating && !_micActive) toggleMic();
} else {
btn.classList.remove('active');
if (inputWrapper) inputWrapper.style.display = '';
if (overlay) overlay.style.display = 'none';
stopAllTts();
stopMic();
inputEl.focus();
}
}
// ── TTS config (must be at top — referenced by addModelBar) ───────────
var currentTtsAudio = null;
var currentTtsPlayer = null;
var TTS_VOICES = {
cs: [
{ id: 'cs-CZ-VlastaNeural', label: 'Vlasta (žena)', engine: 'edge' },
{ id: 'cs-CZ-AntoninNeural', label: 'Antonín (muž)', engine: 'edge' },
{ id: 'browser-cs', label: 'Prohlížeč', engine: 'browser' },
],
en: [
{ id: 'en-US-JennyNeural', label: 'Jenny (female)', engine: 'edge' },
{ id: 'en-US-GuyNeural', label: 'Guy (male)', engine: 'edge' },
{ id: 'browser-en', label: 'Browser', engine: 'browser' },
],
};
var selectedTtsVoice = localStorage.getItem('ttsVoice') || 'cs-CZ-AntoninNeural';
// Theme: dark → light → pink → dark
const themes = ['dark', 'light', 'pink'];
const themeConfig = {
dark: { icon: '&#9790;', label: 'Světlý režim' },
light: { icon: '&#9728;', label: 'Růžový režim' },
pink: { icon: '&#10047;', label: 'Tmavý režim' },
};
function toggleTheme() {
const root = document.documentElement;
const current = root.classList.contains('pink') ? 'pink' :
root.classList.contains('light') ? 'light' : 'dark';
const next = themes[(themes.indexOf(current) + 1) % themes.length];
root.classList.remove('light', 'pink');
if (next !== 'dark') root.classList.add(next);
localStorage.setItem('theme', next);
updateThemeUI(next);
}
function updateThemeUI(theme) {
const cfg = themeConfig[theme] || themeConfig.dark;
document.getElementById('themeIcon').innerHTML = cfg.icon;
document.getElementById('themeLabel').textContent = cfg.label;
}
// Restore saved theme (default: light)
(function() {
const saved = localStorage.getItem('theme') || 'light';
if (saved !== 'dark') {
document.documentElement.classList.add(saved);
}
updateThemeUI(saved);
})();
// Load article stem→title map for linkification
let stemMap = {}; // stem → article title
let linkMode = localStorage.getItem('linkMode') || 'ask'; // 'ask' or 'necyklopedie'
let declensions = {};
// Check STT availability in background
fetch('/api/stt/check').then(r => r.json()).then(data => {
if (!data.available) {
const btn = document.getElementById('micBtn');
if (btn) {
btn.classList.add('mic-unavailable');
const reasons = { no_key: 'Deepgram API klíč není nastaven', invalid_key: 'Deepgram API klíč je neplatný', unreachable: 'Deepgram není dostupný' };
btn.title = reasons[data.reason] || 'Hlasový vstup nedostupný';
}
const vmBtn = document.getElementById('voiceModeBtn');
if (vmBtn) vmBtn.style.display = 'none';
}
}).catch(() => {});
fetch('/api/titles').then(r => r.json()).then(data => {
stemMap = data.stems || {};
declensions = data.declensions || {};
generateExamples(); // re-generate with declensions loaded
console.log('Linkification ready:', Object.keys(stemMap).length, 'stems');
}).catch(() => {});
// Shorten model name for header badge (e.g. "meta/llama-3.3-70b-instruct" → "llama 3.3 70b")
function shortModelName(id) {
if (!id || id === '__auto__') return 'auto';
let s = id.split('/').pop(); // "llama-3.3-70b-instruct"
s = s.replace(/-instruct$/i, ''); // drop -instruct
s = s.replace(/-preview$/i, ''); // drop -preview
s = s.replace(/-latest$/i, ''); // drop -latest
s = s.replace(/-it$/i, ''); // drop -it
s = s.replace(/^@cf\//, ''); // drop @cf/ prefix
s = s.replace(/-/g, ' '); // dashes to spaces
return s;
}
function setModelBadge(id) {
const sel = document.getElementById('modelBadge');
if (sel.tagName === 'SELECT') {
sel.value = id;
if (!sel.value) sel.value = '__auto__'; // fallback if id not in list
} else {
sel.textContent = shortModelName(id);
}
}
function _populateTopBadge(models, defaultModel) {
const sel = document.getElementById('modelBadge');
if (sel.tagName !== 'SELECT') return;
sel.innerHTML = '';
const auto = document.createElement('option');
auto.value = '__auto__'; auto.textContent = '⚡ auto';
sel.appendChild(auto);
const iq = document.createElement('option');
iq.value = '__iq__'; iq.textContent = '🧠 Změřit IQ';
sel.appendChild(iq);
for (const m of models) {
const o = document.createElement('option');
o.value = m.id; o.textContent = shortModelName(m.id);
sel.appendChild(o);
}
sel.value = defaultModel || '__auto__';
}
function onTopModelChange(sel) {
const val = sel.value;
if (val === '__iq__') {
sel.value = '__auto__'; // reset to auto while test runs
runBenchmark();
return;
}
if (val === '__auto__') {
localStorage.removeItem('fixedModel');
_autoModel = null;
selectedModel = availableModels.length ? availableModels[0].id : '';
} else {
localStorage.setItem('fixedModel', val);
selectedModel = val;
_autoModel = null;
}
const smSel = document.getElementById('settingsModelSelect');
if (smSel) smSel.value = val;
}
// Load available models (for per-message dropdowns)
let selectedModel = '';
let activePersona = localStorage.getItem('persona') || 'openajaj';
let personaConfig = {};
// Load personas
fetch('/api/personas').then(r => r.json()).then(data => {
const list = document.getElementById('personaList');
for (const p of data.personas) {
const el = document.createElement('div');
el.className = 'theme-toggle' + (p.id === activePersona ? ' active' : '');
el.style.marginTop = '2px';
el.style.opacity = p.available ? '1' : '0.4';
el.innerHTML = `<span class="theme-icon">${p.logo}</span><span>${p.name}</span>`;
if (p.available) {
el.onclick = () => switchPersona(p.id);
} else {
el.title = 'Data not indexed yet';
}
list.appendChild(el);
}
// Load active persona config
return fetch(`/api/persona/${activePersona}`);
}).then(r => r.json()).then(cfg => {
personaConfig = cfg;
applyPersona(cfg);
}).catch(() => {})
function switchPersona(pid) {
activePersona = pid;
localStorage.setItem('persona', pid);
fetch(`/api/persona/${pid}`).then(r => r.json()).then(cfg => {
personaConfig = cfg;
applyPersona(cfg);
newChat();
// Update persona list highlighting
const list = document.getElementById('personaList');
for (const el of list.children) {
el.classList.toggle('active', el.textContent.includes(cfg.name));
}
// Collapse panels
document.getElementById('advancedPanel').style.display = 'none';
document.getElementById('settingsPanel').style.display = 'none';
});
}
function applyPersona(cfg) {
// Update header
document.querySelector('.header span').textContent = cfg.name;
// Update sidebar logo
const logo = document.querySelector('.logo');
if (logo) logo.innerHTML = cfg.logoImg
? `<img src="${cfg.logoImg}" alt="${cfg.name}">`
: `<span style="font-size:24px;font-weight:bold">${cfg.logo}</span>`;
const h2 = document.querySelector('.sidebar-brand h2');
if (h2) h2.textContent = cfg.name;
// Update welcome
const welcomeLogo = document.querySelector('.welcome-logo');
if (welcomeLogo) welcomeLogo.innerHTML = cfg.logoImg
? `<img src="${cfg.logoImg}" alt="${cfg.name}">`
: `<span style="font-size:36px;font-weight:bold">${cfg.logo}</span>`;
const welcomeH1 = document.querySelector('.welcome h1');
if (welcomeH1) welcomeH1.textContent = cfg.name;
// Don't override the HTML disclaimer with persona text
// Regenerate examples
if (typeof generateExamples === 'function') generateExamples();
}
let availableModels = [];
let _autoModel = null; // per-conversation sticky model (auto mode)
fetch('/api/models').then(r => r.json()).then(data => {
availableModels = data.models;
selectedModel = data.default;
_populateTopBadge(data.models, data.default);
const smSel = document.getElementById('settingsModelSelect');
const autoOpt = document.createElement('option');
autoOpt.value = '__auto__';
autoOpt.textContent = '⚡ Nechat osud rozhodnout (auto)';
smSel.appendChild(autoOpt);
// Active models (top 5 from benchmark)
const active = data.models.filter(m => m.is_active);
const rest = data.models.filter(m => !m.is_active);
for (const m of active) {
const o = document.createElement('option');
o.value = m.id;
const shortName = m.id.split('/').pop().replace(/-/g,' ');
const parts = m.score ? m.score.split('/') : [];
const iq = parts.length === 2 ? Math.round(100 + (parseInt(parts[0]) / parseInt(parts[1]) - 0.5) * 100) : '?';
const rel = m.reliability != null ? ` ${m.reliability}%` : '';
let label = `${shortName} (${m.provider}, IQ ${iq}${rel})`;
if (m.error) label += ' ⚠';
o.textContent = label;
if (m.error) o.title = m.error;
smSel.appendChild(o);
}
if (rest.length) {
const sep = document.createElement('option');
sep.disabled = true;
sep.textContent = '── Další modely ──';
smSel.appendChild(sep);
for (const m of rest) {
const o = document.createElement('option');
o.value = m.id;
const shortName = m.id.split('/').pop().replace(/-/g,' ');
const parts = m.score ? m.score.split('/') : [];
const iq = parts.length === 2 ? Math.round(100 + (parseInt(parts[0]) / parseInt(parts[1]) - 0.5) * 100) : '?';
if (m.error) {
o.textContent = `😵 ${shortName}${m.error}`;
o.disabled = true;
o.style.color = '#9a8455';
} else {
o.textContent = `[${m.provider}] ${shortName} (IQ ${iq})`;
}
smSel.appendChild(o);
}
}
const savedModel = localStorage.getItem('fixedModel');
const validIds = new Set(data.models.map(m => m.id));
if (savedModel && savedModel !== '__auto__' && validIds.has(savedModel)) {
smSel.value = savedModel;
selectedModel = savedModel;
document.getElementById('modelBadge').value = savedModel;
} else {
if (savedModel && !validIds.has(savedModel)) {
localStorage.removeItem('fixedModel');
}
smSel.value = '__auto__';
document.getElementById('modelBadge').value = '__auto__';
}
}).catch(() => {});
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
const welcomeEl = document.getElementById('welcome');
let chatHistory = [];
let isGenerating = false;
let currentChatId = null;
// ── Chat history storage ──
function getStoredChats() {
try { return JSON.parse(localStorage.getItem('chatHistories') || '{}'); } catch { return {}; }
}
function saveCurrentChat() {
if (!currentChatId || chatHistory.length === 0) return;
const chats = getStoredChats();
const firstUserMsg = chatHistory.find(m => m.role === 'user');
chats[currentChatId] = {
title: firstUserMsg ? firstUserMsg.content.slice(0, 50) : 'Nový chat',
messages: chatHistory,
updated: Date.now(),
};
localStorage.setItem('chatHistories', JSON.stringify(chats));
renderHistory();
}
function loadChat(chatId) {
const chats = getStoredChats();
const chat = chats[chatId];
if (!chat) return;
currentChatId = chatId;
chatHistory = chat.messages;
messagesEl.innerHTML = '';
welcomeEl.style.display = 'none';
// Rebuild messages
for (const msg of chatHistory) {
addMessage(msg.role, msg.content);
}
renderHistory();
inputEl.focus();
}
function deleteChat(chatId, event) {
event.stopPropagation();
const chats = getStoredChats();
delete chats[chatId];
localStorage.setItem('chatHistories', JSON.stringify(chats));
if (currentChatId === chatId) newChat();
renderHistory();
}
function clearAllHistory() {
localStorage.removeItem('chatHistories');
newChat();
renderHistory();
}
function resetPreferences() {
localStorage.removeItem('fixedModel');
localStorage.removeItem('theme');
localStorage.removeItem('persona');
localStorage.removeItem('lang');
localStorage.removeItem('ttsEnabled');
localStorage.removeItem('ttsVoice');
location.reload();
}
function toggleSidebar() {
document.querySelector('.sidebar').classList.toggle('open');
document.getElementById('sidebarOverlay').classList.toggle('open');
// Prevent body scroll when sidebar is open on mobile
document.body.style.overflow = document.querySelector('.sidebar.open') ? 'hidden' : '';
}
function toggleSettingsPanel() {
const panel = document.getElementById('settingsPanel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
const brainNames = [
'Zdravý rozum', 'Škola života', 'Selský rozum', 'Třetí oko',
'Šestý smysl', 'Vnitřní hlas', 'Duchovní průvodce', 'Mozkovna',
'Věštecká koule', 'Centrum moudrosti',
];
function randomBrain() { return brainNames[Math.floor(Math.random() * brainNames.length)]; }
// Set initial random brain label
document.getElementById('brainLabel').textContent = randomBrain() + ':';
function onSettingsModelChange() {
const sel = document.getElementById('settingsModelSelect');
const val = sel.value;
if (val === '__auto__') {
localStorage.removeItem('fixedModel');
_autoModel = null;
runBenchmark();
} else {
localStorage.setItem('fixedModel', val);
selectedModel = val;
_autoModel = null;
setModelBadge(val);
toggleSettingsPanel();
}
}
function _benchErrLabel(err) {
if (!err) return '💀 mrtvý';
if (err.includes('chybí klíč')) return '🔑 bez klíče';
if (err.includes('401') || err.includes('Unauthorized')) return '🔑 špatný klíč';
if (err.includes('402') || err.includes('balance') || err.includes('insufficient')) return '💸 bez peněz';
if (err.includes('restricted')) return '🚫 zablokovaný';
if (err.includes('Access denied') || err.includes('403')) return '🚫 zakázaný';
if (err.includes('404') || err.includes('not found')) return '🕵️ nenalezený';
if (err.includes('429') || err.includes('rate') || err.includes('quota') || err.includes('RESOURCE_EXHAUSTED')) return '🍺 přetížený';
if (err.includes('500') || err.includes('Internal')) return '💥 explodoval';
if (err.includes('total') && err.includes('Timeout')) return '🐌 hlemýžď dojde';
if (err.includes('timeout') || err.includes('Timeout') || err.includes('timed out')) return '⏰ usnul u klávesnice';
if (err.includes('decommissioned')) return '⚰️ pohřbený';
if (err.includes('Connection') || err.includes('reset')) return '🔌 odpojený';
return '💀 mrtvý';
}
async function runBenchmark() {
const label = document.getElementById('benchLabel');
const benchResults = document.getElementById('benchResults');
document.getElementById('benchBtn').style.opacity = '0.5';
label.textContent = 'Měřím IQ... 0/15';
benchResults.style.display = 'block';
benchResults.innerHTML = '<div style="font-size:10px;color:var(--text-muted);padding:2px 0">Startuji test...</div>';
const tp = document.getElementById('topBenchPanel');
if (tp) { tp.innerHTML = benchResults.innerHTML; tp.style.display = 'block'; }
const waitMsgs = [
'Vyšetřuji pacienty', 'Testuji rozum',
'Třetí oko vidí', 'Škola života',
'Věštecká koule', 'Měřím IQ',
];
// Poll progress — show live per-model results immediately
let dots = 0, msgIdx = Math.floor(Math.random() * waitMsgs.length);
const progressInterval = setInterval(() => {
dots = (dots + 1) % 4;
if (dots === 0) msgIdx = (msgIdx + 1) % waitMsgs.length;
fetch('/api/bench-status').then(r => r.json()).then(d => {
const models = d.models || {};
const total = d.total || 1;
const done = Object.values(models).filter(m => m.status !== 'testing').length;
label.textContent = waitMsgs[msgIdx] + '.'.repeat(dots+1) + ` ${done}/${total}`;
let html = '<table style="width:100%;font-size:11px;border-collapse:collapse;text-align:left">';
for (const [name, info] of Object.entries(models)) {
const s = info.status;
const icon = s === 'testing' ? '⏳' : s === 'ok' ? '✓' : '✗';
const short = name.split('/').pop().replace(/-/g,' ').slice(0,20);
const color = s === 'ok' ? 'var(--accent)' : s === 'fail' ? '#9a8455' : 'var(--text)';
if (s === 'ok') {
const parts = (info.detail || '').match(/TTFT ([\d.]+)s, (\d+) tok/);
const ttft = parts ? parts[1] + 's' : '';
const tps = parts ? parts[2] + ' t/s' : '';
html += `<tr style="color:${color}"><td>${icon} ${short}</td><td style="text-align:right">${ttft}</td><td style="text-align:right">${tps}</td></tr>`;
} else if (s === 'fail') {
html += `<tr style="color:${color}"><td>${icon} ${short}</td><td colspan="2" style="text-align:right">${_benchErrLabel(info.detail || '')}</td></tr>`;
} else {
html += `<tr style="color:${color}"><td>${icon} ${short}</td><td colspan="2"></td></tr>`;
}
}
html += '</table>';
benchResults.innerHTML = html;
const tp = document.getElementById('topBenchPanel');
if (tp) { tp.innerHTML = html; tp.style.display = 'block'; }
const sb = document.querySelector('.sidebar');
if (sb) sb.scrollTop = sb.scrollHeight;
}).catch(() => {});
}, 400);
try {
const resp = await fetch('/api/benchmark');
const data = await resp.json();
clearInterval(progressInterval);
if (data.best) {
selectedModel = data.best;
localStorage.removeItem('fixedModel');
setModelBadge(data.best);
document.getElementById('settingsModelSelect').value = '__auto__';
const colNames = [
['Vedení', 'Tokání'],
['Reakce', 'Průjem'],
['Rozmysl', 'Výřečnost'],
['Inkubace', 'Tok'],
];
const [col1, col2] = colNames[Math.floor(Math.random() * colNames.length)];
const sorted = Object.entries(data.results).sort((a, b) =>
(parseFloat(a[1].ttft) || 999) - (parseFloat(b[1].ttft) || 999));
let html = '<table style="width:100%;font-size:11px;border-collapse:collapse;text-align:left;color:var(--text)">';
html += `<tr style="font-size:10px;color:var(--text-muted)"><td></td><td style="text-align:right">${col1}</td><td style="text-align:right">${col2}</td></tr>`;
for (const [name, r] of sorted) {
const short = name.split('/').pop().replace(/-/g,' ').slice(0, 18);
const isBest = name === data.best;
const pre = isBest ? '🏆 ' : '';
const style = isBest ? 'color:var(--accent);font-weight:bold' : '';
if (r.ttft) {
const ttft1 = parseFloat(r.ttft).toFixed(1);
const tps1 = parseFloat(r.tok_sec).toFixed(0);
html += `<tr style="${style}"><td>${pre}${short}</td><td style="text-align:right">${ttft1}s</td><td style="text-align:right">${tps1} t/s</td></tr>`;
} else {
html += `<tr style="color:#9a8455"><td>${short}</td><td colspan="2" style="text-align:right">${_benchErrLabel(r.error)}</td></tr>`;
}
}
html += '</table>';
benchResults.innerHTML = html;
const shortName = data.best.split('/').pop().replace(/-/g,' ');
label.textContent = 'Změřit IQ';
const winnerHtml = `<div style="font-size:12px;color:var(--accent);font-weight:bold;padding:2px 0">🏆 ${shortName}</div>`;
benchResults.innerHTML = winnerHtml + benchResults.innerHTML;
const tp = document.getElementById('topBenchPanel');
if (tp) tp.innerHTML = winnerHtml + html;
// Update dropdown to show the chosen model
const smSel = document.getElementById('settingsModelSelect');
smSel.value = data.best;
}
} catch (e) {
clearInterval(progressInterval);
label.textContent = 'Změřit IQ';
}
document.getElementById('benchBtn').style.opacity = '1';
// Scroll sidebar to show results
const sidebar = document.querySelector('.sidebar');
if (sidebar && benchResults.offsetTop) sidebar.scrollTop = benchResults.offsetTop - 100;
}
function toggleAdvanced() {
const panel = document.getElementById('advancedPanel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
const randomLabels = [
'Poselství Jolandy', 'Osud promluvil', 'Hvězdy napověděly',
'Věštba z karet', 'Zjevení z astrálu', 'Hlas nebes',
'Proroctví dne', 'Sudička radí', 'Náhodná moudrost',
];
function updateRandomLabel() {
const el = document.getElementById('randomLabel');
if (el) el.textContent = randomLabels[Math.floor(Math.random() * randomLabels.length)];
}
updateRandomLabel();
function randomArticle() {
const titles = Object.values(stemMap);
if (titles.length === 0) return;
const unique = [...new Set(titles)];
const title = unique[Math.floor(Math.random() * unique.length)];
updateRandomLabel(); // change label for next time
askAbout(title);
}
function renderHistory() {
const list = document.getElementById('historyList');
const chats = getStoredChats();
// Sort by most recent
const sorted = Object.entries(chats).sort((a, b) => (b[1].updated || 0) - (a[1].updated || 0));
list.innerHTML = '';
for (const [id, chat] of sorted) {
const el = document.createElement('div');
el.className = 'history-item' + (id === currentChatId ? ' active' : '');
el.onclick = () => loadChat(id);
el.innerHTML = `${chat.title}<span class="delete-btn" onclick="deleteChat('${id}', event)">&times;</span>`;
list.appendChild(el);
}
}
renderHistory();
// Random example questions — generated after declensions load
function generateExamples() {
const subtitles = [
'Jakou pravdu se dnes chceš dozvědět?',
'Co říkala Jolanda o tvém osudu?',
'Zeptej se — odpovědi jsou zaručeně spolehlivé.',
'Vědění je moc. Necyklopedické vědění je absolutní moc.',
'Astrální archiv je otevřen. Ptej se.',
'Dnes je ideální den na poznání pravdy.',
];
document.getElementById('welcomeSubtitle').textContent =
subtitles[Math.floor(Math.random() * subtitles.length)];
const topics = [
'Chuck Norris', 'Česko', 'Brno', 'Praha', 'Komunismus', 'Slovensko',
'Internet', 'Satan', 'Pardubice', 'Adolf Hitler', 'Star Wars',
'AAAAAAAAA!', 'Kapitalismus', 'Pivo', 'Žena obecná',
'Mikešův máselný paradox', 'Novičok', 'Alžběta II.', 'Boží bojovníci',
];
const shuffled = topics.sort(() => Math.random() - 0.5).slice(0, 4);
const container = document.getElementById('examplesInner');
container.innerHTML = '';
for (const topic of shuffled) {
const d = declensions[topic];
const hasDeclension = d && d.lokal && d.lokal !== topic;
const isKdo = d && d.kdo;
const lokal = hasDeclension ? d.lokal : null;
let questions;
if (hasDeclension && isKdo) {
questions = [`Kdo je ${topic}?`, `Řekni mi o ${lokal}.`];
} else if (hasDeclension) {
questions = [`Co je to ${topic}?`, `Řekni mi o ${lokal}.`, `Jaká je pravda o ${lokal}?`];
} else if (isKdo) {
questions = [`Kdo je ${topic}?`];
} else {
const t = topic.includes(' ') ? `"${topic}"` : topic;
questions = [`Co se skrývá za pojmem ${t}?`, `${t} — co to proboha je?`];
}
const q = questions[Math.floor(Math.random() * questions.length)];
const el = document.createElement('div');
el.className = 'example';
el.textContent = q;
el.onclick = () => { inputEl.value = el.textContent; sendMessage(); };
container.appendChild(el);
}
}
generateExamples();
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
function handleKey(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function newChat() {
saveCurrentChat();
currentChatId = 'chat_' + Date.now();
chatHistory = [];
_autoModel = null; // reset per-conversation model
messagesEl.innerHTML = '';
messagesEl.appendChild(welcomeEl);
welcomeEl.style.display = 'flex';
inputEl.value = '';
inputEl.focus();
renderHistory();
}
function askExample(el) {
inputEl.value = el.textContent;
sendMessage();
}
function askAbout(title) {
const d = declensions[title];
const hasDeclension = d && d.lokal && d.lokal !== title;
const isKdo = d && d.kdo;
const lokal = hasDeclension ? d.lokal : null;
let prompts;
if (hasDeclension && isKdo) {
prompts = [
`Kdo je ${title}?`,
`Řekni mi o ${lokal}.`,
`Jaká je pravda o ${lokal}?`,
];
} else if (hasDeclension) {
prompts = [
`Co je to ${title}?`,
`Řekni mi o ${lokal}.`,
`Jaká je pravda o ${lokal}?`,
`Řekni mi vše o ${lokal}.`,
];
} else if (isKdo) {
prompts = [`Kdo je ${title}?`];
} else {
// No declension at all — use funny "I have no idea" style prompts
const t = title.includes(' ') ? `"${title}"` : title;
// All prompts must be grammar-safe (no prepositions needing declension)
const funnyPrompts = [
`Vysvětli pojem ${t}.`,
`Co se skrývá za pojmem ${t}?`,
`${t} — co to proboha je?`,
`Pojem ${t} mi nic neříká. Pouč mě.`,
];
prompts = funnyPrompts;
}
inputEl.value = prompts[Math.floor(Math.random() * prompts.length)];
sendMessage();
}
function toggleLinkMode() {
linkMode = linkMode === 'ask' ? 'necyklopedie' : 'ask';
localStorage.setItem('linkMode', linkMode);
updateLinkModeUI();
}
function updateLinkModeUI() {
const icon = document.getElementById('linkIcon');
const label = document.getElementById('linkLabel');
if (icon) icon.innerHTML = linkMode === 'ask' ? '&#128172;' : '&#128279;';
if (label) label.textContent = linkMode === 'ask' ? 'Odkazy: vysvětlit' : 'Odkazy: Necyklopedie';
}
// Restore link mode
(function() { updateLinkModeUI(); })();
function addMessage(role, content) {
if (welcomeEl) welcomeEl.style.display = 'none';
const msg = document.createElement('div');
msg.className = `message ${role}`;
const isBot = role === 'assistant';
msg.innerHTML = `
<div class="message-inner">
<div class="avatar ${isBot ? 'bot-avatar' : 'user-avatar'}">
${isBot ? '<img src="/static/logo.png" alt="Aj">' : 'Ty'}
</div>
<div class="message-content">${formatContent(content, isBot)}</div>
</div>
`;
messagesEl.appendChild(msg);
messagesEl.scrollTop = messagesEl.scrollHeight;
return { el: msg, contentEl: msg.querySelector('.message-content') };
}
async function reStreamResponse(contentEl, bar, model, historyIndex, label) {
const spinner = document.createElement('span');
spinner.className = 'retry-spinner';
bar.appendChild(spinner);
// Update label immediately to show which model is being tried
const preInfo = availableModels.find(m => m.id === model);
const preFree = preInfo && preInfo.free ? ' (FREE)' : '';
label.textContent = `${model}${preFree} ...`;
bar.remove();
contentEl.innerHTML = '';
contentEl.appendChild(bar);
const wallStart = performance.now();
const msgsToSend = chatHistory.slice(0, historyIndex);
let serverStats = null;
let sourcesHtml = '';
try {
const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: msgsToSend, model, persona: activePersona }),
});
let fullReply = '';
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let thinking = true;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let doneSignal = false;
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6);
if (raw === '[DONE]') { doneSignal = true; break; }
let parsed;
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
if (typeof parsed === 'string' && parsed.startsWith('__THINKING__')) {
const hint = parsed.slice(12);
while (contentEl.firstChild !== bar) contentEl.firstChild.remove();
const h = document.createElement('span');
h.className = 'thinking-hint';
h.textContent = hint;
contentEl.insertBefore(h, bar);
messagesEl.scrollTop = messagesEl.scrollHeight;
continue;
}
if (parsed && typeof parsed === 'object' && parsed.__sources__) {
const srcUrl = personaConfig.source_url || 'https://necyklopedie.org/wiki/';
sourcesHtml = '<div class="tts-ignore" style="font-size:13px;color:var(--text);margin-bottom:8px;line-height:1.8">'
+ '<span style="opacity:0.7">Zdroje: </span>'
+ parsed.__sources__.map(t =>
`<a href="#" style="color:var(--accent);text-decoration:none;margin-right:8px;cursor:pointer" onclick="_necykPopup(event,'${srcUrl}${encodeURIComponent(t)}')">${t}</a>`
).join('') + '</div>';
continue;
}
if (parsed && typeof parsed === 'object' && parsed.__stats__) {
serverStats = parsed.__stats__;
continue;
}
if (typeof parsed !== 'string') continue;
if (thinking) { thinking = false; }
fullReply += parsed;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = sourcesHtml + formatContent(fullReply, true);
while (contentEl.firstChild !== bar) contentEl.firstChild.remove();
for (const child of [...tempDiv.childNodes]) {
contentEl.insertBefore(child, bar);
}
messagesEl.scrollTop = messagesEl.scrollHeight;
}
if (doneSignal) break; // [DONE] received — exit while loop
}
chatHistory[historyIndex] = { role: 'assistant', content: fullReply };
// Update label and add stats to history
const info = availableModels.find(m => m.id === model);
const freeLabel = info && info.free ? ' (FREE)' : '';
label.textContent = `${model}${freeLabel}`;
const wallTotal = ((performance.now() - wallStart) / 1000).toFixed(1);
const statsEntry = buildStatsHtml(
model + freeLabel,
wallTotal,
serverStats ? serverStats.rag : null,
serverStats ? serverStats.total : null,
serverStats ? serverStats.ttft : null,
serverStats ? serverStats.tok_sec : null,
);
if (statsEntry) {
if (!bar._statsHistory) bar._statsHistory = [];
bar._statsHistory.push(statsEntry);
let existingHist = bar.querySelector('.model-stats-history');
if (existingHist) existingHist.remove();
const histDiv = document.createElement('div');
histDiv.className = 'model-stats-history';
histDiv.style.cssText = 'width:100%';
histDiv.innerHTML = bar._statsHistory.join('');
bar.insertBefore(histDiv, bar.firstChild);
}
} catch (err) {
const errP = document.createElement('p');
errP.textContent = `Ajaj! Chyba: ${err.message}`;
contentEl.insertBefore(errP, bar);
}
addTtsPlayer(contentEl);
spinner.remove();
}
// ── TTS functions ──────────────────────────────────────────────────────
function getTtsText(contentEl) {
const clone = contentEl.cloneNode(true);
clone.querySelectorAll('.msg-model-bar, .tts-player, .tts-ignore, sup, sub').forEach(el => el.remove());
return clone.textContent.trim();
}
function ttsFormatTime(s) {
if (!isFinite(s) || s < 0) return '';
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
function ttsResetPlayer(player) {
if (!player) return;
const btn = player.querySelector('.tts-player-btn');
const fill = player.querySelector('.tts-progress-fill');
const time = player.querySelector('.tts-time');
if (btn) { btn.textContent = '▶'; btn.classList.remove('loading', 'active'); }
if (fill) fill.style.width = '0%';
if (time) time.textContent = '';
}
function stopAllTts() {
// Cancel streaming TTS queue and invalidate in-flight fetches
_ttsGenId++;
_ttsQueue = []; _ttsQueuePlaying = false; _ttsStreamDone = false; _ttsSentenceBuffer = '';
_ttsPrefetch = {};
try { speechSynthesis.cancel(); } catch(e) {}
if (currentTtsAudio) { currentTtsAudio.pause(); currentTtsAudio = null; }
ttsResetPlayer(currentTtsPlayer);
currentTtsPlayer = null;
}
// ── Streaming TTS queue (voice mode) ──────────────────────────────────
var _ttsQueue = [], _ttsQueuePlaying = false, _ttsStreamDone = false, _ttsSentenceBuffer = '';
var _ttsGenId = 0; // incremented on stopAllTts to cancel stale callbacks
function _stripMarkdown(s) {
return s.replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1')
.replace(/#{1,6}\s*/g, '').replace(/`+/g, '').trim();
}
function _voiceTtsAddChunk(chunk) {
_ttsSentenceBuffer += chunk;
// Extract complete sentences ending with . ! ? … (one or more), then whitespace
const re = /[^.!?…]*[.!?…]+\s*/g;
let m, lastEnd = 0;
while ((m = re.exec(_ttsSentenceBuffer)) !== null) {
lastEnd = re.lastIndex;
const s = _stripMarkdown(m[0]);
if (s.length >= 8) _ttsQueuePush(s);
}
if (lastEnd > 0) _ttsSentenceBuffer = _ttsSentenceBuffer.slice(lastEnd);
}
function _voiceTtsFlushRemaining() {
_ttsStreamDone = true;
const rem = _stripMarkdown(_ttsSentenceBuffer);
if (rem.length >= 3) { _ttsQueuePush(rem); _ttsSentenceBuffer = ''; }
// If nothing was queued at all, restart mic immediately
if (!_ttsQueuePlaying && _ttsQueue.length === 0 && _voiceMode) {
updateVoiceModeUI('listening');
setTimeout(() => { if (_voiceMode) toggleMic(); }, 300);
}
}
// Prefetch cache: text → Promise<AudioBuffer> — filled while current sentence plays
var _ttsPrefetch = {};
function _ttsFetchBuf(voiceId, text) {
if (_ttsPrefetch[text]) return _ttsPrefetch[text];
const p = fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voice: voiceId }),
}).then(r => {
if (!r.ok) throw new Error(`TTS ${r.status}`);
return r.arrayBuffer();
}).then(buf => _voiceAudioCtx ? _voiceAudioCtx.decodeAudioData(buf) : Promise.reject('no ctx'));
_ttsPrefetch[text] = p;
return p;
}
function _ttsQueuePush(text) {
_ttsQueue.push(text);
// Kick off prefetch immediately so audio is ready when we need it
if (_voiceAudioCtx && _voiceAudioCtx.state !== 'closed') {
const allVoices = TTS_VOICES.cs.concat(TTS_VOICES.en);
const voice = allVoices.find(v => v.id === selectedTtsVoice) || allVoices[0];
if (voice.engine !== 'browser') _ttsFetchBuf(voice.id, text).catch(() => {});
}
if (!_ttsQueuePlaying) _ttsQueuePlayNext();
}
// Voice mode TTS uses AudioContext (created on user gesture) to bypass autoplay policy.
// Regular HTML Audio() is blocked when called from async/SSE context on Safari.
function _ttsQueuePlayNext() {
if (_ttsQueue.length === 0) {
_ttsQueuePlaying = false;
_ttsPrefetch = {};
if (_ttsStreamDone && _voiceMode) {
updateVoiceModeUI('listening');
setTimeout(() => { if (_voiceMode) toggleMic(); }, 300);
}
return;
}
_ttsQueuePlaying = true;
if (_voiceMode) updateVoiceModeUI('speaking');
const text = _ttsQueue.shift();
const genId = _ttsGenId;
const next = () => { if (_ttsGenId === genId) _ttsQueuePlayNext(); };
const lang = activePersona === 'oyvey' ? 'en-US' : 'cs-CZ';
const allVoices = TTS_VOICES.cs.concat(TTS_VOICES.en);
const voice = allVoices.find(v => v.id === selectedTtsVoice) || allVoices[0];
const browserTtsFallback = () => {
if (_ttsGenId !== genId) return;
const utter = new SpeechSynthesisUtterance(text);
utter.lang = lang;
utter.rate = 1.1;
const bVoices = speechSynthesis.getVoices();
const bMatch = bVoices.find(v => v.lang.startsWith(lang.slice(0, 2)));
if (bMatch) utter.voice = bMatch;
utter.onend = next; utter.onerror = next;
try { speechSynthesis.speak(utter); } catch(e) { next(); }
};
if (voice.engine === 'browser') {
browserTtsFallback(); return;
}
// Use prefetched AudioBuffer (already in-flight since _ttsQueuePush) — no wait
if (_voiceAudioCtx && _voiceAudioCtx.state !== 'closed') {
_ttsFetchBuf(voice.id, text).then(audioBuffer => {
delete _ttsPrefetch[text];
if (!audioBuffer || _ttsGenId !== genId) return;
if (_voiceAudioCtx.state === 'suspended') _voiceAudioCtx.resume();
const src = _voiceAudioCtx.createBufferSource();
src.buffer = audioBuffer;
src.playbackRate.value = 1.1;
src.connect(_voiceAudioCtx.destination);
// Prefetch the next sentence while this one plays
if (_ttsQueue.length > 0) _ttsFetchBuf(voice.id, _ttsQueue[0]).catch(() => {});
src.onended = next;
currentTtsAudio = { pause: () => { try { src.stop(); } catch(e) {} } };
src.start(0);
}).catch(e => {
delete _ttsPrefetch[text];
console.warn('Voice TTS failed, browser fallback:', e.message);
browserTtsFallback();
});
} else {
browserTtsFallback();
}
}
function playBrowserVoice(text, player, lang) {
const btn = player.querySelector('.tts-player-btn');
const utter = new SpeechSynthesisUtterance(text);
utter.lang = lang; utter.rate = 1.0;
const voices = speechSynthesis.getVoices();
const match = voices.find(v => v.lang.startsWith(lang.slice(0, 2)));
if (match) utter.voice = match;
btn.classList.add('active');
btn.textContent = '⏸';
currentTtsPlayer = player;
utter.onend = () => {
ttsResetPlayer(player); currentTtsPlayer = null;
if (_voiceMode && !isGenerating) { updateVoiceModeUI('listening'); toggleMic(); }
};
utter.onerror = () => {
ttsResetPlayer(player); currentTtsPlayer = null;
if (_voiceMode && !isGenerating) { updateVoiceModeUI('listening'); toggleMic(); }
};
speechSynthesis.speak(utter);
}
async function fetchTtsUrl(text, voiceId) {
const resp = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voice: voiceId }),
});
if (!resp.ok) throw new Error('TTS failed');
const blob = await resp.blob();
return URL.createObjectURL(blob);
}
function _wireAndPlay(audio, player) {
const btn = player.querySelector('.tts-player-btn');
const fill = player.querySelector('.tts-progress-fill');
const time = player.querySelector('.tts-time');
const wrap = player.querySelector('.tts-progress-wrap');
currentTtsAudio = audio;
btn.textContent = '⏸'; btn.classList.remove('loading'); btn.classList.add('active');
if (!audio._wired) {
audio._wired = true;
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
fill.style.width = (audio.currentTime / audio.duration * 100) + '%';
time.textContent = ttsFormatTime(audio.currentTime) + ' / ' + ttsFormatTime(audio.duration);
}
});
wrap.onclick = (e) => {
if (!audio.duration) return;
const r = wrap.getBoundingClientRect();
audio.currentTime = ((e.clientX - r.left) / r.width) * audio.duration;
};
audio.onended = () => {
ttsResetPlayer(player); currentTtsAudio = null; currentTtsPlayer = null;
if (audio._url) URL.revokeObjectURL(audio._url);
if (_voiceMode && !isGenerating) {
updateVoiceModeUI('listening');
toggleMic();
}
};
audio.onerror = () => { ttsResetPlayer(player); currentTtsAudio = null; currentTtsPlayer = null; };
}
audio.play().catch(e => {
console.error('TTS play error:', e);
ttsResetPlayer(player); currentTtsAudio = null; currentTtsPlayer = null;
});
}
async function playEdgeVoice(text, player, voiceId) {
currentTtsPlayer = player;
// Fast path: audio object already built and waiting — instant play
if (player._audio) {
_wireAndPlay(player._audio, player);
player._audio = null;
return;
}
// Slow path: still fetching — pulsing bar, no hourglass
player.classList.add('buffering');
try {
let audio = null;
if (player._preloadPromise) {
audio = await player._preloadPromise;
player._preloadPromise = null;
}
if (!audio) {
const url = await fetchTtsUrl(text, voiceId);
audio = new Audio(url); audio._url = url;
}
player.classList.remove('buffering');
_wireAndPlay(audio, player);
} catch(e) {
player.classList.remove('buffering');
ttsResetPlayer(player); currentTtsPlayer = null;
console.error('Edge TTS error:', e);
if (_voiceMode && !isGenerating) { updateVoiceModeUI('listening'); toggleMic(); }
}
}
function handleTtsClick(contentEl, player) {
const btn = player.querySelector('.tts-player-btn');
// Pause/resume if this player is active
if (currentTtsAudio && currentTtsPlayer === player) {
if (currentTtsAudio.paused) {
currentTtsAudio.play(); btn.textContent = '⏸';
} else {
currentTtsAudio.pause(); btn.textContent = '▶';
}
return;
}
stopAllTts();
const text = getTtsText(contentEl);
if (!text) return;
const allVoices = TTS_VOICES.cs.concat(TTS_VOICES.en);
const voice = allVoices.find(v => v.id === selectedTtsVoice) || allVoices[0];
const lang = activePersona === 'oyvey' ? 'en-US' : 'cs-CZ';
if (voice.engine === 'browser') {
playBrowserVoice(text, player, lang);
} else {
playEdgeVoice(text, player, voice.id);
}
}
// ── TTS player setup ──────────────────────────────────────────────────
function addTtsPlayer(contentEl) {
const existing = contentEl.querySelector('.tts-player');
if (existing) existing.remove();
try {
const player = document.createElement('div');
player.className = 'tts-player';
const btn = document.createElement('button');
btn.className = 'tts-player-btn';
btn.textContent = '▶';
btn.title = 'Přečíst nahlas';
btn.addEventListener('click', () => handleTtsClick(contentEl, player));
const wrap = document.createElement('div');
wrap.className = 'tts-progress-wrap';
const fill = document.createElement('div');
fill.className = 'tts-progress-fill';
wrap.appendChild(fill);
const time = document.createElement('span');
time.className = 'tts-time';
const voiceSel = document.createElement('select');
voiceSel.className = 'tts-voice-sel';
voiceSel.title = 'Vybrat hlas';
const lang = activePersona === 'oyvey' ? 'en' : 'cs';
for (const v of (TTS_VOICES[lang] || TTS_VOICES.cs)) {
const o = document.createElement('option');
o.value = v.id; o.textContent = v.label;
if (v.id === selectedTtsVoice) o.selected = true;
voiceSel.appendChild(o);
}
voiceSel.addEventListener('change', () => {
selectedTtsVoice = voiceSel.value;
localStorage.setItem('ttsVoice', selectedTtsVoice);
document.querySelectorAll('.tts-voice-sel').forEach(s => { s.value = selectedTtsVoice; });
});
player.appendChild(btn);
player.appendChild(wrap);
player.appendChild(time);
player.appendChild(voiceSel);
contentEl.appendChild(player);
// Preload audio immediately (background fetch so click is instant)
try {
const allVoices = TTS_VOICES.cs.concat(TTS_VOICES.en);
const voice = allVoices.find(v => v.id === selectedTtsVoice) || allVoices[0];
if (voice.engine !== 'browser') {
const preText = getTtsText(contentEl);
if (preText) player._preloadPromise = fetchTtsUrl(preText, voice.id).then(url => {
const a = new Audio(url); a._url = url;
a.addEventListener('loadedmetadata', () => {
const t = player.querySelector('.tts-time');
if (t && !t.textContent) t.textContent = '0:00 / ' + ttsFormatTime(a.duration);
});
player._audio = a;
return a;
}).catch(() => null);
}
} catch(e) { /* preload best-effort */ }
} catch (e) {
console.error('TTS player failed:', e, e.stack);
}
}
// ── Mic / STT ──────────────────────────────────────────────────────────
let _micWs = null, _micStream = null, _micCtx = null, _micProc = null, _micSrc = null, _micActive = false;
function _f32ToI16(f32) {
const out = new Int16Array(f32.length);
for (let i = 0; i < f32.length; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
out[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return out;
}
function _setMicUI(state) {
// state: 'off' | 'connecting' | 'on'
const btn = document.getElementById('micBtn');
const wrapper = document.querySelector('.input-wrapper');
if (state === 'off') {
btn?.classList.remove('recording');
wrapper?.classList.remove('mic-on', 'mic-connecting');
inputEl.placeholder = 'Ptej se...';
} else if (state === 'connecting') {
btn?.classList.add('recording');
wrapper?.classList.add('mic-connecting');
inputEl.placeholder = '⏳ Připojuji mikrofon…';
} else if (state === 'on') {
wrapper?.classList.remove('mic-connecting');
wrapper?.classList.add('mic-on');
inputEl.placeholder = '🎙 Mluvte… (záznam se ukončí po pauze)';
}
}
function _playChirp(rising) {
// Short chirp via Web Audio — rising for start, falling for end
try {
const ctx = _voiceAudioCtx || new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(rising ? 600 : 900, ctx.currentTime);
osc.frequency.linearRampToValueAtTime(rising ? 900 : 600, ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.1);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.1);
} catch(e) {}
}
function stopMic() {
if (_micActive) _playChirp(false);
_micActive = false;
_setMicUI('off');
if (_micProc) { try { _micProc.disconnect(); } catch(e) {} _micProc = null; }
if (_micSrc) { try { _micSrc.disconnect(); } catch(e) {} _micSrc = null; }
if (_micCtx) { try { _micCtx.close(); } catch(e) {} _micCtx = null; }
if (_micStream) { _micStream.getTracks().forEach(t => t.stop()); _micStream = null; }
const ws = _micWs; _micWs = null;
if (ws && ws.readyState <= 1) {
try { ws.send(JSON.stringify({ type: 'CloseStream' })); } catch(e) {}
ws.close();
}
}
async function toggleMic() {
if (document.getElementById('micBtn')?.classList.contains('mic-unavailable')) return;
if (_micActive || document.querySelector('.input-wrapper.mic-connecting')) { stopMic(); return; }
if (isGenerating) return;
// Create AudioContext synchronously BEFORE any await — Safari loses user gesture context after first await
_micCtx = new (window.AudioContext || window.webkitAudioContext)();
try {
_micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
} catch(e) {
_micCtx.close(); _micCtx = null;
alert('Mikrofon nedostupný: ' + e.message);
return;
}
// Show "connecting" immediately — visible feedback before WebSocket opens
_setMicUI('connecting');
await _micCtx.resume();
const sampleRate = _micCtx.sampleRate;
const lang = activePersona === 'oyvey' ? 'en' : 'cs';
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const _csToken = localStorage.getItem('ajaj_v') || '';
_micWs = new WebSocket(`${proto}//${location.host}/api/stt?lang=${lang}&sample_rate=${Math.round(sampleRate)}&cs=${encodeURIComponent(_csToken)}`);
_micWs.binaryType = 'arraybuffer';
_micWs.onopen = () => {
_micActive = true;
_setMicUI('on');
_playChirp(true);
if (_voiceMode) updateVoiceModeUI('listening');
// Store source in _micSrc — local vars get GC'd and silently break the audio graph
_micSrc = _micCtx.createMediaStreamSource(_micStream);
_micProc = _micCtx.createScriptProcessor(4096, 1, 1);
// Route through a silent gain node — connecting directly to destination causes echo
const silence = _micCtx.createGain();
silence.gain.value = 0;
_micProc.onaudioprocess = e => {
if (_micWs?.readyState === WebSocket.OPEN)
_micWs.send(_f32ToI16(e.inputBuffer.getChannelData(0)).buffer);
};
_micSrc.connect(_micProc);
_micProc.connect(silence);
silence.connect(_micCtx.destination);
};
// 3-second silence timeout — auto-send if no speech detected
let _micSilenceTimer = null;
function _resetSilenceTimer() {
if (_micSilenceTimer) clearTimeout(_micSilenceTimer);
_micSilenceTimer = setTimeout(() => {
if (!_micActive) return;
stopMic();
if (inputEl.value.trim()) {
if (_voiceMode) updateVoiceModeUI('thinking');
sendMessage();
} else if (_voiceMode) {
updateVoiceModeUI('idle');
}
}, 3000);
}
_resetSilenceTimer();
_micWs.onmessage = e => {
let data;
try { data = JSON.parse(e.data); } catch { return; }
const alts = data.channel?.alternatives;
if (!alts?.length) return;
const t = alts[0].transcript?.trim();
if (!t) return;
_resetSilenceTimer();
inputEl.value = t;
autoResize(inputEl);
if (_voiceMode) {
const transcriptEl = document.getElementById('voiceModeTranscript');
if (transcriptEl) transcriptEl.textContent = '"' + t + '"';
}
if (data.is_final && data.speech_final) {
if (_micSilenceTimer) clearTimeout(_micSilenceTimer);
if (_voiceMode) {
const transcriptEl = document.getElementById('voiceModeTranscript');
if (transcriptEl) transcriptEl.textContent = '';
updateVoiceModeUI('thinking');
}
stopMic();
sendMessage();
}
};
_micWs.onerror = e => {
console.error('STT error:', e);
stopMic();
if (_voiceMode) {
updateVoiceModeUI('idle');
} else {
alert('Chyba připojení k serveru STT. Je server spuštěn?');
}
};
_micWs.onclose = () => { if (_micActive) stopMic(); };
}
// ── Model bar (with TTS) ──────────────────────────────────────────────
function buildStatsHtml(modelLabel, wallTotal, ragTime, totalTime, ttft, tokSec) {
let parts = [];
if (wallTotal != null) parts.push(`celkem ${wallTotal}s`);
if (ragTime != null) parts.push(`RAG ${ragTime}s`);
if (totalTime != null) parts.push(`LLM ${totalTime}s`);
if (ttft != null) parts.push(`TTFT ${ttft}s`);
if (tokSec != null) parts.push(`${tokSec} tok/s`);
if (!parts.length) return `<span style="font-size:11px;color:var(--text-muted)">${modelLabel}</span>`;
return `<details style="display:inline"><summary style="cursor:pointer;font-size:11px;color:var(--text-muted);list-style:none">${modelLabel} ▾</summary><div class="model-stats" style="font-size:10px;color:var(--text-muted);padding:2px 0">${parts.join(' · ')}</div></details>`;
}
function addModelBar(msgEl, modelUsed, userQuery, historyIndex, ttft, tokSec, totalTime, ragTime, wallTotal) {
const contentEl = msgEl.querySelector('.message-content');
const bar = document.createElement('div');
bar.className = 'msg-model-bar';
// All on one line: [model ▾ stats] [↻] [Jiný model...]
const modelShort = shortModelName(modelUsed);
const label = document.createElement('span');
label.style.cssText = 'display:inline';
const statsHtml = buildStatsHtml(modelShort, wallTotal, ragTime, totalTime, ttft, tokSec);
label.innerHTML = statsHtml;
const regenBtn = document.createElement('button');
regenBtn.className = 'action-btn';
regenBtn.innerHTML = '&#x21bb;';
regenBtn.title = 'Znovu';
regenBtn.onclick = () => reStreamResponse(contentEl, bar, selectedModel, historyIndex, label);
// Model selector dropdown (regenerate with different model)
const sel = document.createElement('select');
const placeholder = document.createElement('option');
placeholder.textContent = '↻ Jiný model...';
placeholder.value = '';
placeholder.disabled = true;
placeholder.selected = true;
sel.appendChild(placeholder);
const defModels = availableModels.filter(m => m.is_default);
const advModels = availableModels.filter(m => !m.is_default);
for (const m of defModels) {
const o = document.createElement('option');
o.value = m.id;
o.textContent = shortModelName(m.id);
sel.appendChild(o);
}
if (advModels.length) {
const sep = document.createElement('option');
sep.disabled = true;
sep.textContent = '──';
sel.appendChild(sep);
for (const m of advModels) {
const o = document.createElement('option');
o.value = m.id;
o.textContent = shortModelName(m.id);
sel.appendChild(o);
}
}
sel.addEventListener('change', async () => {
const newModel = sel.value;
if (!newModel) return;
selectedModel = newModel;
setModelBadge(newModel);
sel.selectedIndex = 0;
await reStreamResponse(contentEl, bar, newModel, historyIndex, label);
});
bar.appendChild(label);
bar.appendChild(regenBtn);
bar.appendChild(sel);
contentEl.appendChild(bar);
addTtsPlayer(contentEl);
}
function formatContent(text, addLinks = false) {
// Basic markdown: bold, italic, paragraphs
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.split('\n\n').map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('');
// Linkify words matching Necyklopedie article stems (word-by-word lookup, fast)
if (addLinks && Object.keys(stemMap).length > 0) {
// Split HTML into tags and text segments, only linkify text
html = html.replace(/(<[^>]+>)|([^<]+)/g, (full, tag, text) => {
if (tag) return tag; // preserve HTML tags
// Split text into words, check each against stemMap
return text.replace(/[\wáčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ]{4,}/g, (word) => {
const title = stemMap[word] || stemMap[word.toLowerCase()] || stemMap[word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()];
if (!title) return word;
const encoded = encodeURIComponent(title);
const escaped = title.replace(/'/g, "\\'");
return `<a class="necylink" href="#" onclick="event.preventDefault();askAbout('${escaped}')" title="${title}">${word}</a><a href="#" class="necylink-ext" title="Necyklopedie: ${title}" onclick="_necykPopup(event,'https://necyklopedie.org/wiki/${encoded}')"><sup>nc</sup></a>`;
});
});
}
return html;
}
function addTypingIndicator() {
if (welcomeEl) welcomeEl.style.display = 'none';
const msg = document.createElement('div');
msg.className = 'message assistant';
msg.id = 'typing';
msg.innerHTML = `
<div class="message-inner">
<div class="avatar bot-avatar">Aj</div>
<div class="message-content">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
`;
messagesEl.appendChild(msg);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function removeTypingIndicator() {
const el = document.getElementById('typing');
if (el) el.remove();
}
async function sendMessage() {
const text = inputEl.value.trim();
if (!text || isGenerating) return;
isGenerating = true;
sendBtn.disabled = true;
stopAllTts(); // also resets _ttsQueue / _ttsStreamDone
if (_micActive) stopMic();
if (_voiceMode) updateVoiceModeUI('thinking');
inputEl.value = '';
inputEl.style.height = 'auto';
// Ensure we have a chat ID
if (!currentChatId) currentChatId = 'chat_' + Date.now();
// Add user message
addMessage('user', text);
chatHistory.push({ role: 'user', content: text });
// Show assistant message immediately with typing dots
const { el: msgEl, contentEl } = addMessage('assistant', '');
contentEl.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
const wallClockStart = performance.now();
try {
// Determine which model to send: fixed > per-conversation auto > __auto__
const fixedModel = localStorage.getItem('fixedModel');
const chatModel = fixedModel || _autoModel || '__auto__';
const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: chatHistory, model: chatModel, persona: activePersona, voice_mode: _voiceMode }),
});
// Stream response
let fullReply = '';
let thinking = true;
let tokenCount = 0;
let sourcesHtml = '';
let thinkingHtml = '';
let serverStats = null;
let usedModel = chatModel;
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let doneSignal = false;
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6);
if (raw === '[DONE]') { doneSignal = true; break; }
let parsed;
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
// Handle thinking hints (receive all, rotate infinitely with shuffle)
if (typeof parsed === 'string' && parsed.startsWith('__THINKING__')) {
if (!msgEl._thinkingHints) msgEl._thinkingHints = [];
msgEl._thinkingHints.push(parsed.slice(12));
// Show first hint immediately, start rotation on first hint
if (msgEl._thinkingHints.length === 1) {
thinkingHtml = `<span class="thinking-hint">${msgEl._thinkingHints[0]}</span>`;
contentEl.innerHTML = thinkingHtml;
messagesEl.scrollTop = messagesEl.scrollHeight;
msgEl._thinkIdx = 0;
msgEl._thinkTimer = setInterval(() => {
const hints = msgEl._thinkingHints;
if (!hints || hints.length === 0) return;
msgEl._thinkIdx++;
// When we've shown all, shuffle for fresh combos
if (msgEl._thinkIdx >= hints.length) {
msgEl._thinkIdx = 0;
for (let i = hints.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[hints[i], hints[j]] = [hints[j], hints[i]];
}
}
thinkingHtml = `<span class="thinking-hint">${hints[msgEl._thinkIdx]}</span>`;
contentEl.innerHTML = (sourcesHtml || '') + thinkingHtml;
}, 3000);
}
continue;
}
// Handle source articles — show below thinking hint
if (parsed && typeof parsed === 'object' && parsed.__sources__) {
const srcUrl = personaConfig.source_url || 'https://necyklopedie.org/wiki/';
sourcesHtml = '<div class="tts-ignore" style="font-size:13px;color:var(--text);margin-bottom:8px;line-height:1.8">'
+ '<span style="opacity:0.7">Zdroje: </span>'
+ parsed.__sources__.map(t =>
`<a href="#" style="color:var(--accent);text-decoration:none;margin-right:8px;cursor:pointer" onclick="_necykPopup(event,'${srcUrl}${encodeURIComponent(t)}')">${t}</a>`
).join('')
+ '</div>';
contentEl.innerHTML = sourcesHtml + thinkingHtml;
messagesEl.scrollTop = messagesEl.scrollHeight;
continue;
}
// Handle model assignment (auto mode picked this model)
if (parsed && typeof parsed === 'object' && parsed.__model__) {
usedModel = parsed.__model__;
if (!fixedModel) {
_autoModel = usedModel;
selectedModel = usedModel;
setModelBadge(usedModel);
}
continue;
}
// Handle fallback (model switched mid-stream)
if (parsed && typeof parsed === 'object' && parsed.__fallback__) {
usedModel = parsed.__fallback__;
if (!fixedModel) {
_autoModel = usedModel;
selectedModel = usedModel;
setModelBadge(usedModel);
}
continue;
}
// Handle server-side stats
if (parsed && typeof parsed === 'object' && parsed.__stats__) {
serverStats = parsed.__stats__;
usedModel = parsed.__stats__.model || usedModel;
continue;
}
// First real token — stop hint rotation
if (thinking) {
thinking = false;
if (msgEl._thinkTimer) { clearInterval(msgEl._thinkTimer); msgEl._thinkTimer = null; }
}
// Skip non-string tokens
if (typeof parsed !== 'string') continue;
tokenCount++;
fullReply += parsed;
// Skip diagnostic messages from TTS (fallback notices, error warnings)
if (_voiceMode && !parsed.startsWith('[⚠') && !parsed.startsWith('[Model'))
_voiceTtsAddChunk(parsed);
contentEl.innerHTML = sourcesHtml + formatContent(fullReply, true);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
if (doneSignal) break; // [DONE] received — exit while loop
}
chatHistory.push({ role: 'assistant', content: fullReply });
saveCurrentChat();
// Add model info bar with server-side stats + wall-clock total
const wallTotal = ((performance.now() - wallClockStart) / 1000).toFixed(1);
const ttft = serverStats ? serverStats.ttft : null;
const tokSec = serverStats ? serverStats.tok_sec : null;
const totalTime = serverStats ? serverStats.total : null;
const ragTime = serverStats ? serverStats.rag : null;
addModelBar(msgEl, usedModel, text, chatHistory.length - 1, ttft, tokSec, totalTime, ragTime, wallTotal);
} catch (err) {
contentEl.innerHTML = formatContent(`Ajaj! Něco se pokazilo: ${err.message}`, false);
if (_voiceMode) updateVoiceModeUI('idle');
}
isGenerating = false;
sendBtn.disabled = false;
if (_voiceMode) {
_voiceTtsFlushRemaining(); // flush last sentence fragment, restart mic when queue empties
} else {
inputEl.focus();
}
}
let _necykPopupEl = null;
function _necykPopup(event, url) {
event.preventDefault();
event.stopPropagation();
// Remove any existing popup
if (_necykPopupEl) { _necykPopupEl.remove(); _necykPopupEl = null; }
const pop = document.createElement('div');
pop.className = 'necyk-popup';
pop.innerHTML = `
<div>Tohle otevře odkaz na Necyklopedii, ze&nbsp;které OpenAjaj čerpá svá moudrá.</div>
<div class="necyk-popup-btns">
<button onclick="_necykClose()">Zrušit</button>
<button class="primary" onclick="_necykOpen('${url.replace(/'/g, "\\'")}')">Otevřít →</button>
</div>`;
// Position near cursor, keep inside viewport
const vw = window.innerWidth, vh = window.innerHeight;
let x = event.clientX + 10, y = event.clientY + 10;
document.body.appendChild(pop);
const pw = pop.offsetWidth, ph = pop.offsetHeight;
if (x + pw > vw - 8) x = vw - pw - 8;
if (y + ph > vh - 8) y = vh - ph - 8;
pop.style.left = x + 'px';
pop.style.top = y + 'px';
_necykPopupEl = pop;
// Close on outside click
setTimeout(() => document.addEventListener('click', _necykClose, {once: true}), 0);
}
function _necykClose() {
if (_necykPopupEl) { _necykPopupEl.remove(); _necykPopupEl = null; }
}
function _necykOpen(url) {
_necykClose();
window.open(url, '_blank');
}
</script>
</body>
</html>