| <!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 { |
| 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 { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| min-width: 0; |
| } |
| |
| |
| .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 { |
| 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; } |
| |
| |
| .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 { |
| 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 { |
| 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-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 { |
| 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-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); } |
| } |
| |
| |
| ::-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 { |
| 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; } |
| |
| |
| .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; } |
| |
| |
| |
| .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()">×</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">🎲</span> |
| <span id="randomLabel">Poselství Jolandy</span> |
| </div> |
| <div class="theme-toggle" onclick="toggleSettingsPanel()"> |
| <span class="theme-icon">⚙</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">✕</span> |
| <span>Zavřít nastavení</span> |
| </div> |
| <div class="theme-toggle" onclick="toggleTheme();toggleSettingsPanel()" style="margin-top:4px"> |
| <span class="theme-icon" id="themeIcon">☾</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">⚡</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">🗑</span> |
| <span>Smazat historii</span> |
| </div> |
| <div class="theme-toggle" onclick="resetPreferences()" style="margin-top:2px"> |
| <span class="theme-icon">🔄</span> |
| <span>Smazat nastavení</span> |
| </div> |
| <div class="theme-toggle" onclick="toggleAdvanced()" style="margin-top:6px;font-size:11px;opacity:0.6"> |
| <span class="theme-icon">🔬</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()">☰</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"> |
| |
| <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> |
| |
| (function(){ |
| |
| 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; |
| }); |
| }; |
| })(); |
| |
| 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(); |
| |
| if (_voiceMode) { |
| updateVoiceModeUI('listening'); |
| setTimeout(() => { if (_voiceMode && !_micActive && !isGenerating) toggleMic(); }, 100); |
| } |
| return; |
| } |
| if (_micActive) { |
| |
| stopMic(); |
| if (inputEl.value.trim()) { |
| updateVoiceModeUI('thinking'); |
| sendMessage(); |
| } else { |
| updateVoiceModeUI('idle'); |
| } |
| return; |
| } |
| toggleMic(); |
| } |
| |
| var _voiceAudioCtx = null; |
| |
| function toggleVoiceMode() { |
| _voiceMode = !_voiceMode; |
| const inputWrapper = document.querySelector('.input-wrapper'); |
| const overlay = document.getElementById('voiceModeOverlay'); |
| const btn = document.getElementById('voiceModeBtn'); |
| if (_voiceMode) { |
| |
| 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(); |
| } |
| } |
| |
| |
| 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'; |
| |
| |
| const themes = ['dark', 'light', 'pink']; |
| const themeConfig = { |
| dark: { icon: '☾', label: 'Světlý režim' }, |
| light: { icon: '☀', label: 'Růžový režim' }, |
| pink: { icon: '✿', 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; |
| } |
| |
| |
| (function() { |
| const saved = localStorage.getItem('theme') || 'light'; |
| if (saved !== 'dark') { |
| document.documentElement.classList.add(saved); |
| } |
| updateThemeUI(saved); |
| })(); |
| |
| |
| let stemMap = {}; |
| let linkMode = localStorage.getItem('linkMode') || 'ask'; |
| let declensions = {}; |
| |
| 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(); |
| console.log('Linkification ready:', Object.keys(stemMap).length, 'stems'); |
| }).catch(() => {}); |
| |
| |
| function shortModelName(id) { |
| if (!id || id === '__auto__') return 'auto'; |
| let s = id.split('/').pop(); |
| s = s.replace(/-instruct$/i, ''); |
| s = s.replace(/-preview$/i, ''); |
| s = s.replace(/-latest$/i, ''); |
| s = s.replace(/-it$/i, ''); |
| s = s.replace(/^@cf\//, ''); |
| s = s.replace(/-/g, ' '); |
| return s; |
| } |
| |
| function setModelBadge(id) { |
| const sel = document.getElementById('modelBadge'); |
| if (sel.tagName === 'SELECT') { |
| sel.value = id; |
| if (!sel.value) sel.value = '__auto__'; |
| } 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__'; |
| 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; |
| } |
| |
| |
| let selectedModel = ''; |
| let activePersona = localStorage.getItem('persona') || 'openajaj'; |
| let personaConfig = {}; |
| |
| |
| 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); |
| } |
| |
| 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(); |
| |
| const list = document.getElementById('personaList'); |
| for (const el of list.children) { |
| el.classList.toggle('active', el.textContent.includes(cfg.name)); |
| } |
| |
| document.getElementById('advancedPanel').style.display = 'none'; |
| document.getElementById('settingsPanel').style.display = 'none'; |
| }); |
| } |
| |
| function applyPersona(cfg) { |
| |
| document.querySelector('.header span').textContent = cfg.name; |
| |
| 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; |
| |
| 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; |
| |
| |
| if (typeof generateExamples === 'function') generateExamples(); |
| } |
| let availableModels = []; |
| let _autoModel = null; |
| 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); |
| |
| 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; |
| |
| |
| 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'; |
| |
| |
| 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'); |
| |
| 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)]; } |
| |
| |
| 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', |
| ]; |
| |
| |
| 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; |
| |
| const smSel = document.getElementById('settingsModelSelect'); |
| smSel.value = data.best; |
| } |
| } catch (e) { |
| clearInterval(progressInterval); |
| label.textContent = 'Změřit IQ'; |
| } |
| document.getElementById('benchBtn').style.opacity = '1'; |
| |
| 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(); |
| askAbout(title); |
| } |
| |
| function renderHistory() { |
| const list = document.getElementById('historyList'); |
| const chats = getStoredChats(); |
| |
| 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)">×</span>`; |
| list.appendChild(el); |
| } |
| } |
| |
| renderHistory(); |
| |
| |
| 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; |
| 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 { |
| |
| const t = title.includes(' ') ? `"${title}"` : title; |
| |
| 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' ? '💬' : '🔗'; |
| if (label) label.textContent = linkMode === 'ask' ? 'Odkazy: vysvětlit' : 'Odkazy: Necyklopedie'; |
| } |
| |
| |
| (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); |
| |
| |
| 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; |
| } |
| chatHistory[historyIndex] = { role: 'assistant', content: fullReply }; |
| |
| |
| 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(); |
| } |
| |
| |
| |
| 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() { |
| |
| _ttsGenId++; |
| _ttsQueue = []; _ttsQueuePlaying = false; _ttsStreamDone = false; _ttsSentenceBuffer = ''; |
| _ttsPrefetch = {}; |
| try { speechSynthesis.cancel(); } catch(e) {} |
| if (currentTtsAudio) { currentTtsAudio.pause(); currentTtsAudio = null; } |
| ttsResetPlayer(currentTtsPlayer); |
| currentTtsPlayer = null; |
| } |
| |
| |
| var _ttsQueue = [], _ttsQueuePlaying = false, _ttsStreamDone = false, _ttsSentenceBuffer = ''; |
| var _ttsGenId = 0; |
| |
| function _stripMarkdown(s) { |
| return s.replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1') |
| .replace(/#{1,6}\s*/g, '').replace(/`+/g, '').trim(); |
| } |
| |
| function _voiceTtsAddChunk(chunk) { |
| _ttsSentenceBuffer += chunk; |
| |
| 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 (!_ttsQueuePlaying && _ttsQueue.length === 0 && _voiceMode) { |
| updateVoiceModeUI('listening'); |
| setTimeout(() => { if (_voiceMode) toggleMic(); }, 300); |
| } |
| } |
| |
| |
| 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); |
| |
| 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(); |
| } |
| |
| |
| |
| 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; |
| } |
| |
| |
| 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); |
| |
| 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; |
| |
| |
| if (player._audio) { |
| _wireAndPlay(player._audio, player); |
| player._audio = null; |
| return; |
| } |
| |
| |
| 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'); |
| |
| 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); |
| } |
| } |
| |
| |
| |
| 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); |
| |
| |
| 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) { } |
| } catch (e) { |
| console.error('TTS player failed:', e, e.stack); |
| } |
| } |
| |
| |
| |
| 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) { |
| |
| 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) { |
| |
| 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; |
| |
| |
| _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; |
| } |
| |
| |
| _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'); |
| |
| |
| _micSrc = _micCtx.createMediaStreamSource(_micStream); |
| _micProc = _micCtx.createScriptProcessor(4096, 1, 1); |
| |
| |
| 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); |
| }; |
| |
| |
| 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(); }; |
| } |
| |
| |
| |
| 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'; |
| |
| |
| 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 = '↻'; |
| regenBtn.title = 'Znovu'; |
| regenBtn.onclick = () => reStreamResponse(contentEl, bar, selectedModel, historyIndex, label); |
| |
| |
| 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) { |
| |
| let html = text |
| .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
| .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') |
| .split('\n\n').map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join(''); |
| |
| |
| if (addLinks && Object.keys(stemMap).length > 0) { |
| |
| html = html.replace(/(<[^>]+>)|([^<]+)/g, (full, tag, text) => { |
| if (tag) return tag; |
| |
| 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(); |
| if (_micActive) stopMic(); |
| if (_voiceMode) updateVoiceModeUI('thinking'); |
| inputEl.value = ''; |
| inputEl.style.height = 'auto'; |
| |
| |
| if (!currentChatId) currentChatId = 'chat_' + Date.now(); |
| |
| |
| addMessage('user', text); |
| chatHistory.push({ role: 'user', content: text }); |
| |
| |
| 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 { |
| |
| 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 }), |
| }); |
| |
| |
| 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; } |
| |
| |
| if (typeof parsed === 'string' && parsed.startsWith('__THINKING__')) { |
| if (!msgEl._thinkingHints) msgEl._thinkingHints = []; |
| msgEl._thinkingHints.push(parsed.slice(12)); |
| |
| 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++; |
| |
| 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; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| if (parsed && typeof parsed === 'object' && parsed.__model__) { |
| usedModel = parsed.__model__; |
| if (!fixedModel) { |
| _autoModel = usedModel; |
| selectedModel = usedModel; |
| setModelBadge(usedModel); |
| } |
| continue; |
| } |
| |
| |
| if (parsed && typeof parsed === 'object' && parsed.__fallback__) { |
| usedModel = parsed.__fallback__; |
| if (!fixedModel) { |
| _autoModel = usedModel; |
| selectedModel = usedModel; |
| setModelBadge(usedModel); |
| } |
| continue; |
| } |
| |
| |
| if (parsed && typeof parsed === 'object' && parsed.__stats__) { |
| serverStats = parsed.__stats__; |
| usedModel = parsed.__stats__.model || usedModel; |
| continue; |
| } |
| |
| |
| if (thinking) { |
| thinking = false; |
| if (msgEl._thinkTimer) { clearInterval(msgEl._thinkTimer); msgEl._thinkTimer = null; } |
| } |
| |
| |
| if (typeof parsed !== 'string') continue; |
| |
| tokenCount++; |
| fullReply += parsed; |
| |
| if (_voiceMode && !parsed.startsWith('[⚠') && !parsed.startsWith('[Model')) |
| _voiceTtsAddChunk(parsed); |
| contentEl.innerHTML = sourcesHtml + formatContent(fullReply, true); |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| } |
| if (doneSignal) break; |
| } |
| |
| chatHistory.push({ role: 'assistant', content: fullReply }); |
| saveCurrentChat(); |
| |
| |
| 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(); |
| } else { |
| inputEl.focus(); |
| } |
| } |
| |
| let _necykPopupEl = null; |
| function _necykPopup(event, url) { |
| event.preventDefault(); |
| event.stopPropagation(); |
| |
| 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 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> |
|
|