Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

6166 lines
272 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ t('chat.title') }}{% endblock %}
{% block head %}
<!-- Markdown rendering -->
<script src="/admin/static/js/purify.min.js"></script>
<script src="/admin/static/js/marked.umd.js"></script>
<script src="/admin/static/js/marked-highlight.umd.js"></script>
<!-- Code highlighting -->
<link rel="stylesheet" id="hljs-light-theme" href="/admin/static/css/hljs-github.min.css">
<link rel="stylesheet" id="hljs-dark-theme" href="/admin/static/css/hljs-github-dark.min.css" disabled>
<script src="/admin/static/js/highlight.min.js"></script>
<script src="/admin/static/js/hljs-python.min.js"></script>
<script src="/admin/static/js/hljs-javascript.min.js"></script>
<script src="/admin/static/js/hljs-bash.min.js"></script>
<script src="/admin/static/js/hljs-json.min.js"></script>
<!-- Math rendering with KaTeX -->
<link rel="stylesheet" href="/admin/static/css/katex.min.css">
<script defer src="/admin/static/js/katex.min.js"></script>
<script defer src="/admin/static/js/katex-auto-render.min.js"></script>
<style>
/* CSS Variables for theming */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #64748b;
--text-muted: #94a3b8;
--border-faint: #e2e8f0;
--border-normal: #cbd5e1;
--code-bg: #f3f4f6;
--btn-primary: #171717;
--btn-primary-hover: #27272a;
--btn-primary-text: #ffffff;
--text-danger: #ef4444;
--bg-danger-hover: rgba(239, 68, 68, 0.1);
--timeline-accent: #3b82f6;
}
[data-theme="dark"] {
--bg-primary: #1d1d20;
--bg-secondary: #252529;
--bg-tertiary: #2d2d32;
--text-primary: #efefef;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--text-muted: #52525b;
--border-faint: #3f3f46;
--border-normal: #52525b;
--code-bg: #27272a;
--btn-primary: #e5e5e5;
--btn-primary-hover: #d4d4d4;
--btn-primary-text: #18181b;
--text-danger: #f87171;
--bg-danger-hover: rgba(239, 68, 68, 0.2);
--timeline-accent: #60a5fa;
}
body {
background-color: var(--bg-primary) !important;
color: var(--text-primary) !important;
}
html,
body {
height: 100%;
overflow: hidden;
}
.chat-shell {
height: 100vh;
/* fallback for browsers without dvh support */
height: 100dvh;
}
/* Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--border-normal);
border-radius: 3px;
}
/* Sidebar */
.sidebar-width {
width: 260px;
min-width: 260px;
}
.sidebar-hidden {
width: 0 !important;
min-width: 0 !important;
overflow: hidden;
}
.sidebar-transition {
transition: all 0.25s ease;
}
/* Messages */
.message-fade-in {
animation: fadeIn 0.3s ease;
position: relative;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.response-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
justify-content: flex-end;
}
.response-tab {
border: 1px solid var(--border-normal);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.response-tab:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.response-tab--active {
color: var(--text-primary);
font-weight: 500;
}
.response-tab-group {
display: inline-flex;
align-items: center;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid var(--border-normal);
background: var(--bg-secondary);
transition: border-color 0.15s ease, background 0.15s ease;
}
.response-tab-group:hover {
background: color-mix(in srgb, var(--timeline-accent) 10%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--timeline-accent) 40%, var(--border-normal));
}
.response-tab-group .response-tab {
flex: 1 1 auto;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
min-width: 0;
border: none;
border-radius: 0;
background: transparent;
padding: 0.35rem 0.5rem;
}
.response-tab-group .response-tab:hover {
background: transparent;
color: var(--text-primary);
}
.response-tab-group.response-tab-group--active {
border: 1.5px solid var(--timeline-accent);
}
.response-tab-group.response-tab-group--active:hover {
background: color-mix(in srgb, var(--timeline-accent) 12%, var(--bg-secondary));
}
.response-tab-group.response-tab-group--active-missing {
border: 1.5px solid #ef4444;
}
.response-tab-group.response-tab-group--active-missing:hover {
background: color-mix(in srgb, #ef4444 12%, var(--bg-secondary));
border-color: #ef4444;
}
.response-tab-streaming-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--timeline-accent);
animation: pulse 1.5s ease-in-out infinite;
}
.response-tab-delete {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
align-self: stretch;
padding: 0.35rem 0.4rem;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.response-tab-delete:hover {
background: var(--bg-danger-hover, rgba(239, 68, 68, 0.12));
color: var(--text-danger);
}
.assistant-message {
background: var(--bg-primary);
border: 1px solid var(--border-faint);
border-radius: 16px;
max-width: 768px;
}
.message-header {
padding: 8px 12px;
border-bottom: 1px solid var(--border-faint);
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-primary);
}
.message-header svg {
color: var(--text-tertiary);
}
.message-body {
padding: 12px;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
overflow-x: auto;
min-width: 0;
}
.user-message {
max-width: min(768px, 100%);
align-self: flex-end;
padding-bottom: 32px;
}
.user-message.is-editing {
width: min(768px, 100%);
padding-bottom: 0;
}
.user-message-bubble {
background: var(--bg-secondary);
border-radius: 24px;
padding: 8px 16px;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
max-width: 100%;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.user-message-bubble p {
color: var(--text-primary) !important;
margin: 0 0 1em;
}
.user-message-bubble p:last-child {
margin-bottom: 0;
}
.user-message-bubble * {
color: var(--text-primary);
}
.user-message-bubble pre {
max-width: 100%;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
background-color: var(--code-bg);
border-radius: 0.5em;
padding: 0.75em 1em;
margin: 0.5em 0 0;
}
.user-message-bubble code {
background-color: var(--code-bg);
border-radius: 0.25em;
padding: 0.125em 0.25em;
font-size: 0.875em;
}
.user-message-bubble pre code {
background: transparent;
padding: 0;
}
/* Loading dots */
.loading-dots {
display: flex;
gap: 4px;
align-items: center;
}
.loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
animation: pulse 1.4s infinite;
}
.loading-dot:nth-child(2) {
animation-delay: 0.2s;
}
.loading-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
/* Markdown content */
.markdown-content {
line-height: 1.6;
color: var(--text-primary);
min-width: 0;
overflow-wrap: break-word;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.5em;
color: var(--text-primary);
}
.markdown-content h1 {
font-size: 1.5em;
}
.markdown-content h2 {
font-size: 1.3em;
}
.markdown-content h3 {
font-size: 1.1em;
}
.markdown-content p {
margin-bottom: 1em;
color: var(--text-primary);
}
.markdown-content ul,
.markdown-content ol {
margin-bottom: 1em;
padding-left: 1.5em;
color: var(--text-primary);
}
.markdown-content li {
margin-bottom: 0.25em;
color: var(--text-primary);
}
.markdown-content code {
background-color: var(--code-bg);
padding: 0.125em 0.25em;
border-radius: 0.25em;
font-size: 0.875em;
color: var(--text-primary);
}
.markdown-content pre {
background-color: var(--code-bg);
border-radius: 0.5em;
padding: 1em;
margin: 1em 0;
overflow-x: auto;
}
.markdown-content pre code {
background: none;
padding: 0;
color: var(--text-primary);
}
.markdown-content table {
border-collapse: collapse;
margin: 1em 0;
font-size: 0.875em;
display: block;
overflow-x: auto;
max-width: 100%;
}
.markdown-content th,
.markdown-content td {
border: 1px solid var(--border-faint);
padding: 0.5em 0.75em;
text-align: left;
word-break: break-word;
}
.markdown-content th {
background: var(--bg-secondary);
font-weight: 600;
}
.markdown-content tr:nth-child(even) {
background: var(--bg-secondary);
}
/* Code block copy button */
.code-block-wrapper {
position: relative;
}
.code-copy-btn {
background: var(--bg-secondary);
border: 1px solid var(--border-faint);
border-radius: 0.375em;
padding: 0.25em 0.5em;
font-size: 0.7em;
cursor: pointer;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 0.35em;
flex-shrink: 0;
}
.code-copy-btn:hover {
background: var(--bg-tertiary);
}
.svg-render-btn {
background: var(--bg-secondary);
border: 1px solid var(--border-faint);
border-radius: 0.375em;
padding: 0.25em 0.5em;
font-size: 0.7em;
cursor: pointer;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 0.35em;
flex-shrink: 0;
}
.svg-render-btn:hover {
background: var(--bg-tertiary);
}
.code-btn-row {
position: absolute;
top: 0.5em;
right: 0.5em;
display: flex;
gap: 0.35em;
opacity: 0;
transition: opacity 0.2s;
z-index: 1;
}
.code-block-wrapper:hover .code-btn-row {
opacity: 1;
}
.svg-preview-container {
margin: 0.5em 0;
padding: 1em;
background: #ffffff;
border: 1px solid var(--border-faint);
border-radius: 0.5em;
text-align: center;
overflow: auto;
max-height: 2160px;
}
.svg-preview-container svg {
max-width: 100%;
height: auto;
}
.svg-allow-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.svg-allow-label {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
flex: 1;
}
.svg-allow-switch {
position: relative;
display: inline-flex;
align-items: center;
width: 2rem;
height: 1.125rem;
flex-shrink: 0;
border: none;
border-radius: 9999px;
padding: 0;
cursor: pointer;
background: var(--border-normal);
transition: background-color 0.2s ease;
}
.svg-allow-switch.is-on {
background: var(--timeline-accent);
}
.svg-allow-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: transform 0.2s ease;
}
.svg-allow-switch.is-on .svg-allow-switch-knob {
transform: translateX(0.875rem);
}
.svg-allow-warning {
margin-top: 0.25rem;
font-size: 10px;
line-height: 1.35;
color: var(--text-tertiary);
}
/* User message edit button */
.user-message {
position: relative;
}
.user-message-actions {
position: absolute;
right: 0;
bottom: 0;
left: auto;
top: auto;
display: flex;
flex-direction: row;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.user-message-action-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.15s;
}
.user-message:hover .user-message-actions {
opacity: 1;
}
.user-message-action-btn:hover {
background: var(--bg-secondary);
color: var(--text-secondary);
}
/* Inline edit */
.inline-edit-textarea {
width: 100%;
min-width: 280px;
min-height: 80px;
max-height: 300px;
box-sizing: border-box;
resize: vertical;
padding: 12px 16px;
border: 2px solid var(--border-normal);
border-radius: 16px;
background: var(--bg-primary);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
line-height: 1.6;
outline: none;
}
.inline-edit-actions {
display: flex;
gap: 8px;
margin-top: 8px;
justify-content: flex-end;
}
/* Thinking container */
.thinking-container {
background: var(--bg-secondary);
border: 1px solid var(--border-faint);
border-radius: 0.75rem;
margin: 0.5em 0.75rem;
overflow: hidden;
}
.thinking-header {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
color: var(--text-secondary);
gap: 0.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-faint);
}
.thinking-header:hover {
background: var(--bg-tertiary);
}
.thinking-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.thinking-text {
flex: 1;
font-size: inherit;
font-weight: inherit;
font-family: inherit;
}
.thinking-toggle {
width: 14px;
height: 14px;
transition: transform 0.2s ease;
transform: rotate(180deg);
}
.thinking-toggle.collapsed {
transform: rotate(0deg);
}
.thinking-content {
padding: 1rem;
font-size: 0.8125rem;
line-height: 1.6;
color: var(--text-secondary);
background: var(--bg-secondary);
max-height: 400px;
overflow-y: auto;
}
/* Preserve thinking text style when markdown-content class is also applied */
.thinking-markdown {
font-size: 0.8125rem !important;
color: var(--text-secondary) !important;
}
.thinking-markdown p {
margin-bottom: 0.5em;
color: var(--text-secondary);
}
.thinking-markdown p:last-child {
margin-bottom: 0;
}
.thinking-markdown h1,
.thinking-markdown h2,
.thinking-markdown h3 {
color: var(--text-secondary);
font-size: 0.9rem;
}
.thinking-markdown li {
color: var(--text-secondary);
}
.thinking-markdown code {
font-size: 0.75rem;
}
.thinking-markdown pre {
font-size: 0.75rem;
}
.thinking-content.collapsed {
display: none;
}
/* Input area */
.input-container {
border: 1px solid var(--border-normal);
border-radius: 1.5rem;
background: var(--bg-primary);
transition: border-color 0.2s;
}
.input-container:focus-within {
border-color: var(--text-tertiary);
}
.input-container textarea {
box-shadow: none !important;
}
.input-container textarea:focus {
box-shadow: none !important;
}
.input-container textarea::placeholder {
color: var(--text-muted);
}
/* API Key Modal */
.api-key-modal.hidden {
display: none;
}
/* Drag and drop state */
.input-container.drag-over {
border-color: var(--text-tertiary) !important;
border-style: dashed !important;
background-color: var(--bg-tertiary);
}
/* Mobile responsive */
@media (max-width: 767px) {
.sidebar-width {
position: fixed;
z-index: 50;
height: 100%;
}
.sidebar-hidden {
transform: translateX(-100%);
}
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 40;
}
.sidebar-overlay.hidden {
display: none;
}
.user-message-actions {
right: 0;
left: auto;
}
/* Prevent iOS auto-zoom on input focus (requires >= 16px) */
textarea,
input[type="text"],
input[type="password"] {
font-size: 16px !important;
}
}
/* Image modal */
.image-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: zoom-out;
}
.image-modal img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
}
.image-modal-close {
position: absolute;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.image-modal-close:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Edit image preview */
.edit-images-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
padding: 8px;
background: var(--bg-tertiary);
border-radius: 8px;
}
.edit-images-preview .edit-image-item {
position: relative;
}
.edit-images-preview img {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border-faint);
}
/* Right Sidebar styling */
.right-sidebar-width {
width: 320px;
min-width: 320px;
}
.right-sidebar-hidden {
width: 0 !important;
min-width: 0 !important;
overflow: hidden;
border-left: none !important;
}
.right-sidebar-transition {
transition: all 0.25s ease;
}
/* Mobile/tablet responsive for right sidebar */
@media (max-width: 1024px) {
.right-sidebar-width {
position: fixed;
right: 0;
z-index: 50;
height: 100%;
}
.right-sidebar-hidden {
transform: translateX(100%);
border-left: none !important;
}
.right-sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 40;
}
.right-sidebar-overlay.hidden {
display: none;
}
}
/* Timeline Rail */
.timeline-rail-line {
position: absolute;
left: 50%;
top: 16px;
bottom: 16px;
width: 1px;
transform: translateX(-50%);
background: var(--border-normal);
opacity: 0.4;
pointer-events: none;
}
.timeline-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: transparent;
border: 0.5px solid var(--text-tertiary);
transition: all 0.15s ease;
cursor: pointer;
flex-shrink: 0;
pointer-events: auto;
}
.timeline-dot:hover {
border-color: var(--timeline-accent) !important;
background: var(--timeline-accent) !important;
}
.timeline-dot-active {
background: var(--timeline-accent) !important;
border: 1.5px solid var(--timeline-accent) !important;
transform: scale(1.2);
}
.timeline-tooltip-content {
background: var(--bg-primary);
border: 1px solid var(--border-normal);
border-radius: 8px;
padding: 8px 12px;
max-width: 260px;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
font-size: 12px;
color: var(--text-primary);
line-height: 1.4;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
</style>
{% endblock %}
{% block content %}
<script>
// Apply theme immediately before render to prevent flash (match applyTheme / Alpine default)
(function () {
const stored = localStorage.getItem('omlx-chat-theme');
let theme = stored || 'auto';
if (theme === 'auto') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<div class="chat-shell flex overflow-hidden" x-data="chatApp()" x-init="init()">
<!-- API Key Modal -->
<div class="api-key-modal fixed inset-0 bg-black/50 flex items-center justify-center z-[1000]"
:class="{ 'hidden': apiKeySet }" x-cloak>
<div class="bg-surface rounded-2xl p-8 max-w-[400px] w-[90%] shadow-2xl">
<h2 class="text-xl font-bold mb-2">{{ t('login.login.label_api_key') }}</h2>
<p class="text-sm text-fg-tertiary mb-4">{{ t('chat.api_key_prompt') }}</p>
<input type="password" x-model="apiKeyInput" @keydown.enter="saveApiKey"
placeholder="{{ t('chat.api_key_placeholder') }}"
class="w-full px-4 py-3 border border-line rounded-xl mb-4 focus:outline-none focus:border-line-strong"
style="background: var(--bg-primary); color: var(--text-primary);">
<button @click="saveApiKey"
class="w-full bg-accent text-accent-fg px-6 py-3 rounded-xl font-medium transition-all hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!apiKeyInput.trim()">
{{ t('chat.api_key_continue') }}
</button>
</div>
</div>
<!-- Sidebar Overlay (mobile) -->
<div class="sidebar-overlay" :class="{ 'hidden': !sidebarOpen }" @click="sidebarOpen = false" x-cloak></div>
<!-- Sidebar -->
<div class="sidebar-width h-full bg-surface border-r border-line flex flex-col sidebar-transition"
:class="{ 'sidebar-hidden': !sidebarOpen }">
<!-- Sidebar Header -->
<div class="p-4 border-b border-line flex-shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<a href="/admin/dashboard">
<img x-show="activeTheme === 'light'" src="/admin/static/navbar-logo-light.svg" alt="oMLX"
class="w-[30px] h-[30px]">
<img x-show="activeTheme === 'dark'" src="/admin/static/navbar-logo-dark.svg" alt="oMLX"
class="w-[30px] h-[30px]" x-cloak>
</a>
<div class="flex flex-col gap-0">
<a href="/admin/dashboard" class="font-semibold leading-tight">{{ t('chat.brand') }}</a>
<a :href="updateAvailable && releaseUrl ? releaseUrl : 'https://github.com/jundot/omlx'" target="_blank"
class="relative text-[10px] leading-none hover:opacity-80 transition-opacity"
style="color: var(--text-muted);"
@mouseenter="versionHover = true" @mouseleave="versionHover = false">
v{{ version }}
<span x-show="updateAvailable" x-cloak
class="inline-block w-1.5 h-1.5 bg-green-500 rounded-full ml-0.5 align-middle"></span>
<span x-show="versionHover" x-cloak
class="absolute left-1/2 -translate-x-1/2 top-full mt-1 px-2 py-1 text-[10px] text-white bg-neutral-800 rounded whitespace-nowrap z-50"
x-text="updateAvailable && latestVersion
? window.t('navbar.update_available').replace('{version}', latestVersion)
: window.t('navbar.go_to_github')"></span>
</a>
</div>
</div>
<button @click="sidebarOpen = false"
class="p-1 hover:bg-surface-alt rounded-lg transition-colors"
:title="window.t('chat.close_sidebar') || 'Close sidebar'">
<i data-lucide="panel-left-close" class="w-4 h-4" style="color: var(--text-tertiary);"></i>
</button>
</div>
</div>
<!-- New Chat Button -->
<div class="p-3 flex-shrink-0">
<button @click="startNewChat()"
class="w-full flex items-center gap-2 px-3 py-2 bg-surface-muted hover:bg-surface-alt rounded-lg text-sm font-medium transition-colors"
style="color: var(--text-primary);">
<i data-lucide="plus" class="w-4 h-4"></i>
{{ t('chat.new_chat') }}
</button>
</div>
<!-- Chat History -->
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3">
<template x-for="chat in chatHistory" :key="chat.id">
<div @click="if (renamingChatId !== chat.id) loadChat(chat.id)"
class="group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-surface-muted mb-1"
:class="{ 'bg-surface-muted': currentChatId === chat.id }">
<i data-lucide="message-square" class="w-4 h-4 flex-shrink-0" style="color: var(--text-muted);"></i>
<button type="button" x-show="isChatStreaming(chat.id)" @click.stop="stopChatStreaming(chat.id)"
class="w-2 h-2 rounded-full flex-shrink-0 animate-pulse p-0 border-0 cursor-pointer"
style="background: var(--timeline-accent);" title="Stop generating"></button>
<template x-if="renamingChatId === chat.id">
<div class="flex items-center gap-1 flex-1 min-w-0">
<input x-model="renameDraft" x-ref="renameInput" @click.stop
@keydown.enter.prevent="saveRenamedChat(chat.id)"
@keydown.escape.prevent="cancelRenamingChat()"
class="flex-1 min-w-0 text-sm px-2 py-1 rounded-md border border-line focus:outline-none focus:border-line-strong"
style="background: var(--bg-primary); color: var(--text-primary);"
x-init="$nextTick(() => { $el.focus(); $el.select(); })">
<button @click.stop="saveRenamedChat(chat.id)"
class="p-1 hover:bg-surface-alt rounded transition-colors"
:title="window.t('chat.rename_save_tooltip')">
<i data-lucide="check" class="w-3 h-3" style="color: var(--success, #16a34a);"></i>
</button>
<button @click.stop="cancelRenamingChat()"
class="p-1 hover:bg-surface-alt rounded transition-colors"
:title="window.t('chat.rename_cancel_tooltip')">
<i data-lucide="x" class="w-3 h-3" style="color: var(--text-muted);"></i>
</button>
</div>
</template>
<template x-if="renamingChatId !== chat.id">
<div class="flex items-center gap-0 flex-1 min-w-0">
<span class="text-sm truncate flex-1" style="color: var(--text-primary);"
:title="displayChatTitle(chat)"
x-text="displayChatTitle(chat)"></span>
<button @click.stop="startRenamingChat(chat)"
class="opacity-0 group-hover:opacity-100 p-1 hover:bg-surface-alt rounded transition-opacity"
title="{{ t('chat.rename_tooltip') }}">
<i data-lucide="pencil" class="w-3 h-3" style="color: var(--text-muted);"></i>
</button>
<button @click.stop="deleteChat(chat.id)"
class="opacity-0 group-hover:opacity-100 p-1 hover:bg-danger-bg rounded transition-opacity"
title="{{ t('chat.delete_tooltip') }}">
<i data-lucide="trash-2" class="w-3 h-3 text-danger"></i>
</button>
</div>
</template>
</div>
</template>
</div>
<!-- Sidebar Footer -->
<div class="p-2 border-t border-line space-y-0.5 flex-shrink-0">
<a href="/admin/dashboard"
class="flex items-center gap-1.5 px-2 py-1 text-xs text-fg-tertiary hover:bg-surface-muted rounded">
<i data-lucide="settings" class="w-3 h-3"></i>
{{ t('chat.admin_settings') }}
</a>
<div class="svg-allow-setting px-2 py-1.5">
<div class="svg-allow-row">
<div class="svg-allow-label">
<i data-lucide="wand-sparkles" class="w-3 h-3 shrink-0" style="color: var(--text-tertiary);"></i>
<span class="text-xs truncate" style="color: var(--text-tertiary);"
x-text="window.t('chat.allow_svg')"></span>
</div>
<button type="button" role="switch" class="svg-allow-switch"
:class="{ 'is-on': allowSvg }"
:aria-checked="allowSvg ? 'true' : 'false'"
:aria-label="window.t('chat.allow_svg')"
@click="setAllowSvg(!allowSvg)">
<span class="svg-allow-switch-knob" aria-hidden="true"></span>
</button>
</div>
<p class="svg-allow-warning" x-text="window.t('chat.allow_svg_warning')"></p>
</div>
<!-- Theme Select -->
<div class="relative w-full">
<div
class="flex items-center gap-1.5 px-2 py-1 text-xs text-fg-tertiary hover:bg-surface-muted rounded">
<i data-lucide="sun-moon" class="w-3 h-3" x-show="theme === 'auto'"></i>
<i data-lucide="sun" class="w-3 h-3" x-show="theme === 'light'"></i>
<i data-lucide="moon" class="w-3 h-3" x-show="theme === 'dark'"></i>
<select @change="setTheme($event.target.value)"
class="flex-1 bg-transparent border-none outline-none cursor-pointer appearance-none text-inherit"
x-model="theme">
<option value="auto">{{ t('navbar.theme.auto') }}</option>
<option value="light">{{ t('navbar.theme.light_mode') }}</option>
<option value="dark">{{ t('navbar.theme.dark_mode') }}</option>
</select>
</div>
</div>
<button @click="clearAllHistory()" x-show="chatHistory.length > 0"
class="w-full flex items-center gap-1.5 px-2 py-1 text-xs text-danger hover:bg-danger-bg rounded">
<i data-lucide="trash-2" class="w-3 h-3"></i>
{{ t('chat.clear_all_history') }}
</button>
<button @click="clearApiKey()"
class="w-full flex items-center gap-1.5 px-2 py-1 text-xs text-fg-tertiary hover:bg-surface-muted rounded">
<i data-lucide="key" class="w-3 h-3"></i>
{{ t('chat.change_api_key') }}
</button>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 flex flex-col bg-surface overflow-hidden min-w-0 relative">
<!-- Floating Sidebar Toggles -->
<button @click="sidebarOpen = true"
class="absolute p-1.5 bg-surface hover:bg-surface-muted rounded-lg border border-line z-[100] shadow-sm"
style="top: 1rem; left: 1rem; color: var(--text-primary);"
x-show="showLeftToggle">
<i data-lucide="menu" class="w-5 h-5"></i>
</button>
<button @click="rightSidebarOpen = true" class="absolute p-1.5 bg-surface hover:bg-surface-muted rounded-lg border border-line z-[100] shadow-sm"
style="top: 1rem; right: 1rem; color: var(--text-primary);"
:title="'Show settings'"
x-show="showRightToggle">
<i data-lucide="settings" class="w-5 h-5"></i>
</button>
<!-- Messages area with rail -->
<div class="flex-1 min-h-0 relative">
<!-- Messages Container -->
<div class="absolute inset-0 overflow-y-auto overflow-x-hidden custom-scrollbar" id="messagesContainer">
<!-- Welcome Screen -->
<div x-show="messages.length === 0" class="h-full flex items-center justify-center" x-cloak>
<div class="text-center max-w-lg px-4">
<div class="flex items-center justify-center gap-3 mb-4">
<img x-show="activeTheme === 'light'" src="/admin/static/logo-light.svg" alt="oMLX"
class="w-[60px] h-[60px]">
<img x-show="activeTheme === 'dark'" src="/admin/static/logo-dark.svg" alt="oMLX"
class="w-[60px] h-[60px]" x-cloak>
<h1 class="text-2xl font-bold">{{ t('chat.welcome_heading') }}</h1>
</div>
<p class="text-fg-tertiary mb-6">{{ t('chat.welcome_description') }}</p>
<div class="p-4 bg-surface-alt rounded-xl text-sm text-fg-secondary">
<i data-lucide="shield-check" class="w-4 h-4 inline mr-1"></i>
{{ t('chat.welcome_privacy') }}
</div>
</div>
</div>
<!-- Messages -->
<div x-show="messages.length > 0" class="max-w-4xl mx-auto px-4 py-6 space-y-4">
<template x-for="(msg, index) in messages" :key="msg.id">
<div class="message-fade-in" x-show="isMessageVisible(msg, index)"
:data-msg-index="index">
<!-- User Message -->
<div x-show="msg.role === 'user'" class="flex justify-end">
<div class="user-message" :class="{ 'is-editing': editingIndex === index }">
<div x-show="editingIndex !== index" class="user-message-actions" x-data="{ contextOpen: false }">
<button @click="copyMessage(msg.content)" class="user-message-action-btn"
title="{{ t('chat.copy_tooltip') }}">
<i data-lucide="copy" class="w-4 h-4"></i>
</button>
<button @click="startEdit(index)" class="user-message-action-btn"
title="{{ t('chat.edit_tooltip') }}">
<i data-lucide="pencil" class="w-4 h-4"></i>
</button>
<div class="relative">
<button @click="contextOpen = !contextOpen" @click.outside="contextOpen = false"
class="user-message-action-btn" title="More actions">
<i data-lucide="more-vertical" class="w-4 h-4"></i>
</button>
<div x-show="contextOpen" x-transition
class="absolute right-0 top-full mt-1 z-50 bg-surface border border-line rounded-lg shadow-lg py-1 min-w-[150px]"
style="min-width: 160px; border-color: var(--border-normal);">
<button @click="branchChat(index); contextOpen = false"
class="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-surface-muted"
style="color: var(--text-primary);">
<i data-lucide="git-branch" class="w-3.5 h-3.5"></i>
Branch Here
</button>
<button @click="deleteMessage(index); contextOpen = false"
class="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger hover:bg-danger-bg">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
Delete
</button>
<button @click="copyMarkdown(msg); contextOpen = false"
class="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-surface-muted"
style="color: var(--text-primary);">
<i data-lucide="file-text" class="w-3.5 h-3.5"></i>
Copy Markdown
</button>
</div>
</div>
</div>
<template x-if="editingIndex !== index">
<div>
<!-- Images from content array -->
<template x-if="getImageUrls(msg.content).length > 0">
<div class="flex flex-wrap gap-2 mb-2">
<template x-for="imgUrl in getImageUrls(msg.content)"
:key="imgUrl.substring(0, 50)">
<img :src="imgUrl" @click="openImageModal(imgUrl)"
class="max-w-48 max-h-48 object-cover rounded-lg cursor-pointer">
</template>
</div>
</template>
<!-- Placeholder for stripped images (restored from localStorage) -->
<template x-if="hasStrippedImages(msg.content)">
<div class="flex flex-wrap gap-2 mb-2">
<template x-for="(_, i) in getStrippedImageCount(msg.content)" :key="i">
<div
class="w-20 h-20 bg-surface-muted border border-dashed border-line-strong rounded-lg flex flex-col items-center justify-center text-fg-muted text-[10px] gap-0.5">
<i data-lucide="image" class="w-5 h-5"></i>
<span
x-text="window.t('chat.image_not_available') || 'Image'"></span>
</div>
</template>
</div>
</template>
<template x-if="getDocumentFiles(msg.content).length > 0">
<div class="flex flex-wrap gap-2 mb-2">
<template x-for="doc in getDocumentFiles(msg.content)" :key="doc.filename">
<div class="inline-flex items-center gap-2 max-w-full px-2.5 py-1.5 rounded-lg border border-line bg-surface-muted text-xs">
<i data-lucide="file-text" class="w-4 h-4 flex-shrink-0"></i>
<span class="truncate max-w-56" x-text="doc.filename || window.t('chat.document_not_available')"></span>
</div>
</template>
</div>
</template>
<!-- Text content -->
<template x-if="getTextContent(msg.content)">
<div class="user-message-bubble markdown-content"
x-html="renderMarkdown(getTextContent(msg.content))"></div>
</template>
</div>
</template>
<template x-if="editingIndex === index">
<div class="user-message-bubble" style="padding: 0; background: transparent;">
<!-- Show preserved images during edit -->
<div x-show="editImages.length > 0" class="edit-images-preview">
<template x-for="(img, idx) in editImages" :key="img.substring(0, 50)">
<div class="edit-image-item">
<img :src="img" :alt="window.t('chat.image_preview') || 'Image'">
</div>
</template>
</div>
<textarea x-model="editContent"
@keydown.enter.ctrl="if (!$event.isComposing && $event.keyCode !== 229) saveEdit(index)"
@keydown.escape="cancelEdit" class="inline-edit-textarea"
x-init="$nextTick(() => { $el.focus(); $el.setSelectionRange($el.value.length, $el.value.length); })"></textarea>
<div class="inline-edit-actions">
<button @click="cancelEdit"
class="px-3 py-1.5 text-sm border rounded-lg hover:bg-surface-muted"
style="border-color: var(--border-normal); color: var(--text-secondary);">{{
t('chat.edit_cancel') }}</button>
<button @click="saveEdit(index)"
class="px-3 py-1.5 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-hover"
style="color: var(--btn-primary-text);">{{ t('chat.edit_save')
}}</button>
</div>
</div>
</template>
<template x-if="getTurnVariantsForUser(index).length > 1">
<div class="response-tabs w-full">
<template x-for="(variant, vi) in getTurnVariantsForUser(index)"
:key="variant.id">
<div class="response-tab-group"
:class="{
'response-tab-group--active': isActiveVariantForUser(index, variant.id) && !variantModelMissing(variant),
'response-tab-group--active-missing': isActiveVariantForUser(index, variant.id) && variantModelMissing(variant),
}">
<span x-show="isVariantTabStreaming(index, variant.id)"
class="response-tab-streaming-dot ml-1"
title="Generating"></span>
<button type="button" class="response-tab text-xs"
:class="{ 'response-tab--active': isActiveVariantForUser(index, variant.id) }"
@click="selectVariant(index, variant.id)"
x-text="variantTabLabel(variant, vi)"></button>
<button type="button" class="response-tab-delete"
title="Delete this response"
@click="deleteVariant(index, variant.id, $event)">
<i data-lucide="trash-2" class="w-3 h-3"></i>
</button>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Assistant Message -->
<div x-show="msg.role === 'assistant' && msg._ui !== false" class="assistant-message">
<div class="message-header">
<div class="flex items-center gap-2 text-sm font-medium"
style="color: var(--text-primary);">
<i data-lucide="cpu" class="w-4 h-4" style="color: var(--text-tertiary);"></i>
<span x-text="variantModelLabel(msg)"></span>
<span x-show="variantProfileLabel(msg)"
class="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-muted border border-line"
style="color: var(--text-tertiary);"
x-text="variantProfileLabel(msg)"></span>
</div>
<div class="flex gap-1" x-data="{ contextOpen: false }">
<!-- Timeline toggle: only visible when timeline data is attached -->
<button x-show="msg._perfLog?.length > 0"
@click="msg._perfVisible = !msg._perfVisible"
class="w-7 h-7 flex items-center justify-center rounded-md bg-transparent cursor-pointer transition-all hover:bg-surface-alt"
:style="msg._perfVisible ? 'color:var(--text-primary);' : 'color:var(--text-tertiary);'"
:title="msg._perfVisible ? 'Hide info' : 'Show info (' + msg._perfTotal + ')'">
<i data-lucide="activity" class="w-4 h-4"></i>
</button>
<button @click="copyMarkdown(msg)"
class="w-7 h-7 flex items-center justify-center rounded-md bg-transparent text-fg-tertiary cursor-pointer transition-all hover:bg-surface-alt hover:text-fg-secondary"
title="{{ t('chat.copy_tooltip') }}">
<i data-lucide="copy" class="w-4 h-4"></i>
</button>
<button @click="regenerateMessage(index)"
class="w-7 h-7 flex items-center justify-center rounded-md bg-transparent text-fg-tertiary cursor-pointer transition-all hover:bg-surface-alt hover:text-fg-secondary"
title="{{ t('chat.regenerate_tooltip') }}">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
<div class="relative flex-shrink-0">
<button @click="contextOpen = !contextOpen" @click.outside="contextOpen = false"
class="w-7 h-7 flex items-center justify-center rounded-md bg-transparent text-fg-tertiary cursor-pointer transition-all hover:bg-surface-alt hover:text-fg-secondary">
<i data-lucide="more-vertical" class="w-4 h-4"></i>
</button>
<div x-show="contextOpen" x-transition
class="absolute right-0 top-full mt-1 z-50 bg-surface border border-line rounded-lg shadow-lg py-1 min-w-[150px]"
style="min-width: 160px; border-color: var(--border-normal);">
<button @click="branchChat(index); contextOpen = false"
class="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-surface-muted"
style="color: var(--text-primary);">
<i data-lucide="git-branch" class="w-3.5 h-3.5"></i>
Branch here
</button>
<button @click="deleteMessage(index); contextOpen = false"
class="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger hover:bg-danger-bg">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
Delete
</button>
<button @click="copyMarkdown(msg); contextOpen = false"
class="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-surface-muted"
style="color: var(--text-primary);">
<i data-lucide="file-text" class="w-3.5 h-3.5"></i>
Copy markdown
</button>
</div>
</div>
</div>
</div>
<!-- Collapsible performance timeline -->
<div x-show="msg._perfVisible === true && msg._perfLog?.length > 0"
class="px-1 pt-1 pb-2 mb-1 border-b space-y-0.5"
style="border-color: var(--border-normal);">
<template x-for="(entry, ei) in msg._perfLog" :key="ei">
<div class="flex items-center gap-2 text-xs font-mono"
style="color: var(--text-muted);">
<span x-text="entry.t"
style="min-width:6ch;flex-shrink:0;color:var(--text-tertiary);"></span>
<i x-show="entry.icon === 'wrench'" data-lucide="wrench"
class="w-3 h-3 flex-shrink-0"></i>
<span x-show="entry.icon === 'check'"
style="color:var(--text-success);">&#10003;</span>
<span x-show="entry.icon === 'error'"
style="color:var(--text-danger);">&#10007;</span>
<span x-text="entry.text"></span>
</div>
</template>
<p class="text-xs font-mono mt-1" style="color: var(--text-tertiary);"
x-text="msg._perfTotal"></p>
</div>
<!-- Thinking bubble (collapsed by default) -->
<template x-if="msg._thinking">
<div class="thinking-container mt-2">
<div class="thinking-header" @click="msg._thinkingOpen = !msg._thinkingOpen">
<svg class="thinking-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<span class="thinking-text">Thinking</span>
<svg class="thinking-toggle" :class="{ collapsed: !msg._thinkingOpen }"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15" />
</svg>
</div>
<div class="thinking-content markdown-content thinking-markdown"
:class="{ collapsed: !msg._thinkingOpen }" data-math-pending="true"
:data-math-version="msg._thinking ? String(msg._thinking.length) : '0'"
x-html="renderMarkdown(msg._thinking)"></div>
</div>
</template>
<div class="message-body markdown-content" data-math-pending="true"
:data-math-version="typeof msg.content === 'string' ? String(msg.content.length) : '0'"
x-html="renderMarkdown(msg.content)"></div>
</div>
</div>
</template>
<!-- Streaming Message -->
<div x-show="isCurrentChatStreaming()" x-cloak class="assistant-message message-fade-in">
<div class="message-header">
<div class="flex items-center gap-2 text-sm font-medium" style="color: var(--text-primary);">
<i data-lucide="cpu" class="w-4 h-4" style="color: var(--text-tertiary);"></i>
<span
x-text="currentStream()?.sourceModel || currentModel || window.t('chat.assistant_label')"></span>
<span x-show="currentStream()?.sourceProfile && currentStream()?.sourceProfile !== 'System Default'"
class="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-muted border border-line"
style="color: var(--text-tertiary);"
x-text="currentStream()?.sourceProfile"></span>
</div>
<div class="flex gap-1">
<!-- Timeline toggle for streaming -->
<button @click="toggleCurrentStreamTimeline()"
class="w-7 h-7 flex items-center justify-center rounded-md bg-transparent cursor-pointer transition-all hover:bg-surface-alt"
:style="currentStream()?.statusTimelineVisible ? 'color:var(--text-primary);' : 'color:var(--text-tertiary);'"
title="Toggle info">
<i data-lucide="activity" class="w-4 h-4"></i>
</button>
<button @click="stopStreaming()"
class="w-7 h-7 flex items-center justify-center rounded-md bg-transparent text-fg-tertiary cursor-pointer transition-all hover:bg-surface-alt hover:text-fg-secondary"
title="{{ t('chat.stop_tooltip') }}">
<i data-lucide="square" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="message-body">
<!-- Accumulated timeline entries — only shown when toggled open -->
<div x-show="currentStream()?.statusTimelineVisible && currentStream()?.statusLog?.length > 0"
class="space-y-0.5 mb-1 mt-0.5">
<template x-for="(entry, idx) in (currentStream()?.statusLog || [])" :key="idx">
<div class="flex items-center gap-2 text-xs font-mono"
style="color:var(--text-tertiary);">
<span x-text="entry.t" style="min-width:6ch;flex-shrink:0;opacity:0.5;"></span>
<!-- Inline SVGs avoid Lucide re-init issues inside x-for -->
<span x-show="entry.icon === 'wrench'" style="flex-shrink:0;display:inline-flex;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
</span>
<span x-show="entry.icon === 'check'"
style="color:var(--text-success);flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
<span x-show="entry.icon === 'error'"
style="color:var(--text-danger);flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</span>
<span x-text="entry.text"></span>
</div>
</template>
</div>
<!-- Live status line — always visible while streaming, hidden once final content arrives -->
<div x-show="!currentStream()?.finalContent"
class="flex items-center gap-2 text-xs font-mono py-1" style="color:var(--text-tertiary);">
<span x-show="currentStream()?.engineStatus?.icon === 'wrench'"
style="flex-shrink:0;display:inline-flex;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
</span>
<span x-text="currentStream()?.engineStatus?.text || 'Starting'"></span>
<span class="loading-dots" style="display:inline-flex;gap:2px;margin:0;">
<span class="loading-dot" style="width:4px;height:4px;"></span>
<span class="loading-dot" style="width:4px;height:4px;"></span>
<span class="loading-dot" style="width:4px;height:4px;"></span>
</span>
</div>
<!-- Streaming content (thinking block + final answer) -->
<!-- Live thinking bubble — shown while reasoning_content is arriving -->
<div x-show="currentStream()?.streamingThinking" class="thinking-container mb-2">
<div class="thinking-header" @click="toggleCurrentStreamThinking()">
<svg class="thinking-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<span class="thinking-text"
x-text="currentStream()?.finalContent ? 'Thinking' : 'Thinking...' "></span>
<svg class="thinking-toggle"
:class="{ collapsed: !currentStream()?.streamingThinkingOpen }" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15" />
</svg>
</div>
<div class="thinking-content markdown-content thinking-markdown"
:class="{ collapsed: !currentStream()?.streamingThinkingOpen }" data-math-pending="true"
:data-math-version="String((currentStream()?.streamingThinking || '').length)"
x-html="renderMarkdown(currentStream()?.streamingThinking || '')"></div>
</div>
<div x-show="currentStream()?.streamingContent" class="markdown-content" x-ref="streamingOutput"
x-effect="currentStream()?.isStreaming && updateStreamingDOM(currentStream()?.streamingContent)"></div>
</div>
</div>
</div>
</div>
<!-- Timeline rail -->
<div x-show="messages.length > 0 && timelineDots.length > 0"
class="absolute top-0 h-full z-20 pointer-events-none"
style="width:20px;right:max(0px,calc(50% - 468px));">
<div class="timeline-rail-line"></div>
<template x-for="dot in timelineDots" :key="dot.index">
<div class="absolute left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-auto cursor-pointer"
:style="'top:' + dot.topPx + 'px;width:9px;height:9px;display:flex;align-items:center;justify-content:center;border-radius:50%;'"
@mouseenter="showDotTooltip($event, messages[dot.index], dot.index)"
@mouseleave="hideDotTooltip()"
@click="scrollToMessage(dot.index)">
<span class="timeline-dot"
:class="timelineActive === dot.index ? 'timeline-dot-active' : ''"></span>
</div>
</template>
</div>
</div>
<!-- Timeline tooltip -->
<div x-show="timelineTooltip.show" x-transition
class="fixed pointer-events-none z-50"
:style="'left:' + timelineTooltip.x + 'px;top:' + timelineTooltip.y + 'px;'">
<div class="timeline-tooltip-content" x-html="timelineTooltip.content"></div>
</div>
<!-- Input Area -->
<div class="p-4 bg-surface flex-shrink-0">
<div class="max-w-4xl mx-auto">
<div class="input-container" :class="{ 'drag-over': isDragOver }" @dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false" @drop.prevent="isDragOver = false; handleDrop($event)">
<!-- Hidden file input for image upload (multiple) -->
<input type="file" id="imageInput" accept="image/*" multiple x-ref="imageInput"
@change="handleImageSelect($event)" class="hidden">
<input type="file" id="documentInput" accept=".pdf,.docx,.pptx,.txt,.md,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain,text/markdown,text/x-markdown" multiple x-ref="documentInput"
@change="handleDocumentSelect($event)" class="hidden">
<!-- Image preview area (multi-image) -->
<div x-show="uploadImages.length > 0" class="flex flex-wrap gap-2 px-2 pt-2" x-cloak>
<template x-for="(img, idx) in uploadImages" :key="img.id">
<div class="relative group">
<div x-show="!img.base64"
class="h-16 w-16 rounded-lg border border-line bg-surface-muted animate-pulse"></div>
<img x-show="img.base64" :src="img.base64"
class="h-16 w-16 object-cover rounded-lg border border-line">
<button @click="removeImage(idx)"
class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center bg-surface-alt border border-line-strong text-fg-tertiary cursor-pointer transition-all opacity-0 group-hover:opacity-100 hover:bg-surface-muted hover:text-fg"
:title="window.t('chat.remove_image')">
<i data-lucide="x" class="w-3 h-3"></i>
</button>
</div>
</template>
</div>
<div x-show="uploadDocuments.length > 0" class="flex flex-wrap gap-2 px-2 pt-2" x-cloak>
<template x-for="(doc, idx) in uploadDocuments" :key="doc.id">
<div class="relative group flex items-center gap-2 max-w-full px-2.5 py-1.5 rounded-lg border border-line bg-surface-muted text-xs">
<i data-lucide="file-text" class="w-4 h-4 flex-shrink-0" style="color: var(--text-tertiary);"></i>
<span class="truncate max-w-48" x-text="doc.filename"></span>
<span x-show="!doc.base64" class="text-[10px]" style="color: var(--text-tertiary);">...</span>
<button @click="removeDocument(idx)"
class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center bg-surface-alt border border-line-strong text-fg-tertiary cursor-pointer transition-all opacity-0 group-hover:opacity-100 hover:bg-surface-muted hover:text-fg"
:title="window.t('chat.remove_document')">
<i data-lucide="x" class="w-3 h-3"></i>
</button>
</div>
</template>
</div>
<div class="flex items-end">
<!-- Image upload button (only for VLM models) -->
<button x-show="hasVisionSupport()" @click="$refs.imageInput.click()"
:disabled="!apiKeySet || !currentModel || isCurrentChatStreaming() || uploadImages.length >= 10"
class="w-10 h-10 m-1 shrink-0 bg-surface-muted hover:bg-surface rounded-full border border-line transition-colors flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
:title="window.t('chat.upload_image')">
<i data-lucide="image" class="w-4 h-4" style="color: var(--text-tertiary);"></i>
</button>
<button x-show="hasDocumentSupport()" @click="$refs.documentInput.click()"
:disabled="!apiKeySet || !currentModel || isCurrentChatStreaming() || uploadDocuments.length >= markitdownSettings.max_files_per_request"
class="w-10 h-10 m-1 shrink-0 bg-surface-muted hover:bg-surface rounded-full border border-line transition-colors flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
:title="window.t('chat.upload_document')">
<i data-lucide="file-text" class="w-4 h-4" style="color: var(--text-tertiary);"></i>
</button>
<textarea x-ref="messageInput" x-model="inputMessage"
@keydown.enter="if (!isMobile && !$event.shiftKey && !$event.isComposing && $event.keyCode !== 229) { $event.preventDefault(); sendMessage(); }"
@input="autoResize($event.target)" @paste="handlePaste($event)"
:placeholder="isMobile ? window.t('chat.input_placeholder_mobile') : window.t('chat.input_placeholder')"
:disabled="!apiKeySet || !currentModel || isCurrentChatStreaming()" rows="1"
class="flex-1 px-4 py-3 bg-transparent resize-none outline-none text-sm max-h-32"
style="color: var(--text-primary);"></textarea>
<div class="relative flex-shrink-0">
<button x-show="!isCurrentChatStreaming()" @click="sendMessage()"
:disabled="(!inputMessage.trim() && uploadImages.length === 0 && uploadDocuments.length === 0) || hasPendingUploads() || !apiKeySet || !currentModel"
class="w-10 h-10 m-1 bg-accent hover:bg-accent-hover disabled:bg-surface-muted rounded-full transition-colors flex items-center justify-center">
<i data-lucide="arrow-up" class="w-4 h-4 text-accent-fg"></i>
</button>
<button x-show="isCurrentChatStreaming()" @click="stopStreaming()"
class="w-10 h-10 m-1 bg-surface-muted hover:bg-surface rounded-full border border-line transition-colors flex items-center justify-center">
<div class="w-3 h-3 rounded-[2px]" style="background-color: var(--text-danger);"></div>
</button>
<!-- Image count badge -->
<div x-show="uploadImages.length > 0"
class="absolute -top-0.5 -right-0.5 w-4.5 h-4.5 bg-accent text-accent-fg text-[10px] flex items-center justify-center rounded-full pointer-events-none"
x-text="uploadImages.length"></div>
<div x-show="uploadDocuments.length > 0 && uploadImages.length === 0"
class="absolute -top-0.5 -right-0.5 w-4.5 h-4.5 bg-accent text-accent-fg text-[10px] flex items-center justify-center rounded-full pointer-events-none"
x-text="uploadDocuments.length"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Sidebar Overlay (mobile) -->
<div class="right-sidebar-overlay" :class="{ 'hidden': !rightSidebarOpen }" @click="rightSidebarOpen = false"
x-cloak></div>
<!-- Right Sidebar -->
<div class="right-sidebar-width h-full bg-surface border-l border-line flex flex-col right-sidebar-transition"
:class="{ 'right-sidebar-hidden': !rightSidebarOpen }" x-cloak>
<!-- Sidebar Header with tabs -->
<div class="p-4 border-b border-line flex-shrink-0">
<div class="flex items-center gap-1">
<div class="flex flex-1 items-center gap-1">
<button @click="rightSidebarTab = 'settings'"
class="flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-colors"
:class="rightSidebarTab === 'settings'
? 'bg-neutral-200 dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800'">
<i data-lucide="settings" class="w-3 h-3"></i>
<span>MODEL</span>
</button>
<button @click="rightSidebarTab = 'profile'"
class="flex-1 flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-colors"
:class="rightSidebarTab === 'profile'
? 'bg-neutral-200 dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800'">
<i data-lucide="user" class="w-3 h-3"></i>
<span>PROFILE</span>
</button>
</div>
<button @click="rightSidebarOpen = false"
class="p-1 hover:bg-surface-alt rounded-lg transition-colors"
title="Close settings">
<i data-lucide="panel-right-close" class="w-4 h-4" style="color: var(--text-tertiary);"></i>
</button>
</div>
</div>
<!-- Tab Content: Scrollable area -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- ==================== PROFILE TAB ==================== -->
<div x-show="rightSidebarTab === 'profile'" class="px-4 py-3 space-y-3">
<!-- Profile list -->
<div class="space-y-0.5">
<template x-for="p in promptProfiles" :key="p.name">
<div>
<!-- Normal row -->
<template x-if="!(editingPromptProfileName && activePromptProfile === p.name)">
<div @click="selectPromptProfile(p.name)"
class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer group transition-colors"
:class="activePromptProfile === p.name ? 'bg-surface-muted' : 'hover:bg-surface-muted'">
<span class="w-2 h-2 rounded-full flex-shrink-0"
:class="activePromptProfile === p.name ? 'bg-blue-500' : 'border border-line'"></span>
<span class="flex-1 text-xs font-medium truncate"
style="color: var(--text-primary);" x-text="p.name"></span>
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
:class="{'!opacity-100': activePromptProfile === p.name}">
<button x-show="p.name !== 'System Default'" @click.stop="startRenamingPromptProfile()"
class="p-1 rounded hover:bg-surface-alt transition-colors" title="Rename">
<i data-lucide="pencil" class="w-3 h-3" style="color: var(--text-tertiary);"></i>
</button>
<template x-if="profileDeleteConfirm !== p.name && p.name !== 'System Default'">
<button @click.stop="profileDeleteConfirm = p.name"
class="p-1 rounded hover:bg-red-50 transition-colors" title="Delete">
<i data-lucide="trash-2" class="w-3 h-3 text-danger"></i>
</button>
</template>
<template x-if="profileDeleteConfirm === p.name">
<span class="flex items-center gap-0.5">
<button @click.stop="deletePromptProfile(p.name)"
class="p-1 text-green-600 hover:bg-surface-muted rounded">
<i data-lucide="check" class="w-3 h-3"></i>
</button>
<button @click.stop="profileDeleteConfirm = null"
class="p-1 text-red-500 hover:bg-surface-muted rounded">
<i data-lucide="x" class="w-3 h-3"></i>
</button>
</span>
</template>
</div>
</div>
</template>
<!-- Rename inline input -->
<template x-if="editingPromptProfileName && activePromptProfile === p.name">
<div class="flex items-center gap-2 px-3 py-1">
<input x-model="newProfileNameDraft"
@keydown.enter.prevent="commitRenamingPromptProfile()"
@keydown.escape.prevent="cancelRenamingPromptProfile()"
@click.stop
class="flex-1 px-2 py-1 text-xs border border-line rounded-md bg-surface-muted"
style="color: var(--text-primary);"
placeholder="Profile name..."
autofocus>
<button @click.stop="commitRenamingPromptProfile()"
class="p-1 text-green-600 hover:bg-surface-muted rounded">
<i data-lucide="check" class="w-3 h-3"></i>
</button>
<button @click.stop="cancelRenamingPromptProfile()"
class="p-1 text-red-500 hover:bg-surface-muted rounded">
<i data-lucide="x" class="w-3 h-3"></i>
</button>
</div>
</template>
</div>
</template>
</div>
<!-- Add Profile button -->
<button @click="addNewPromptProfile()"
class="w-full flex items-center gap-2 px-3 py-2 mt-2 rounded-lg text-xs transition-colors hover:bg-surface-muted"
style="color: var(--text-tertiary);">
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
<span>Add Profile</span>
</button>
<!-- Prompt textarea -->
<div class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider block"
style="color: var(--text-secondary);">Prompt Content</label>
<textarea x-model="systemPrompt" rows="5"
@input="promptDirty = (activePromptProfile && systemPrompt !== promptProfiles.find(p => p.name === activePromptProfile)?.content); const s = getChatSession(currentChatId, true); if (s) s.systemPrompt = systemPrompt;"
placeholder="Enter instructions for this session..."
class="w-full px-3 py-2 text-xs border border-line rounded-xl focus:outline-none focus:border-line-strong transition-all resize-y bg-surface-muted"
style="color: var(--text-primary);"></textarea>
</div>
<!-- Save -->
<div class="flex justify-center pt-1">
<button @click="savePromptProfile()"
:disabled="!activePromptProfile || !promptDirty"
class="flex items-center gap-1.5 px-4 py-2 rounded-lg text-[10px] font-semibold transition-all duration-200"
:class="promptDirty && activePromptProfile
? 'bg-neutral-900 text-white hover:bg-neutral-800 dark:bg-neutral-100 dark:text-neutral-900'
: 'bg-neutral-200 text-neutral-500 cursor-not-allowed'">
<i data-lucide="save" class="w-3 h-3"></i>
<span>Save Setting</span>
</button>
</div>
</div>
<!-- ==================== MODEL TAB ==================== -->
<div x-show="rightSidebarTab === 'settings'" class="px-4 py-3 space-y-3">
<!-- Active Profile selector (shared with PROFILE tab) -->
<div class="space-y-1">
<div class="flex items-center gap-1.5 mb-1">
<i data-lucide="user" class="w-3 h-3" style="color: var(--text-secondary);"></i>
<label class="text-[10px] font-bold uppercase tracking-wider"
style="color: var(--text-secondary);">Active Profile</label>
</div>
<div class="relative">
<select
:value="activePromptProfile || ''"
@change="selectPromptProfile($event.target.value || null)"
class="w-full px-3 py-2 text-xs border border-line rounded-xl font-medium cursor-pointer appearance-none focus:outline-none focus:border-line-strong transition-colors bg-surface-muted hover:bg-surface-alt"
style="color: var(--text-primary);">
<template x-for="p in promptProfiles" :key="p.name">
<option :value="p.name" x-text="p.name"></option>
</template>
</select>
<i data-lucide="chevron-down"
class="w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"
style="color: var(--text-tertiary);"></i>
</div>
</div>
<!-- Active Model Selector -->
<div class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider block"
style="color: var(--text-secondary);">Active Model</label>
<div class="relative">
<select x-model="currentModel"
@change="selectModel($event.target.value)"
class="w-full px-3 py-2 text-xs border border-line rounded-xl font-medium cursor-pointer appearance-none focus:outline-none focus:border-line-strong transition-colors bg-surface-muted hover:bg-surface-alt"
style="color: var(--text-primary);">
<template x-for="model in availableModels" :key="model.id">
<option :value="model.id" x-text="model.name"></option>
</template>
</select>
<i data-lucide="chevron-down"
class="w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"
style="color: var(--text-tertiary);"></i>
</div>
<div x-show="availableModels.length === 0">
{{ t('chat.no_models') }}
</div>
</div>
<!-- Temperature & Max Tokens -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider"
style="color: var(--text-secondary);">Temperature</label>
<input type="number" x-model.number="modelSettings.temperature" @input="onModelSettingsChange()"
step="0.1" min="0" max="2" placeholder="Default"
class="w-full px-3 py-2 text-xs border border-line rounded-xl focus:outline-none focus:border-line-strong bg-surface-muted"
style="color: var(--text-primary);">
</div>
<div class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider"
style="color: var(--text-secondary);">Max Tokens</label>
<input type="number" x-model.number="modelSettings.max_tokens" @input="onModelSettingsChange()"
min="1" placeholder="Default"
class="w-full px-3 py-2 text-xs border border-line rounded-xl focus:outline-none focus:border-line-strong bg-surface-muted"
style="color: var(--text-primary);">
</div>
</div>
<!-- Sampling Advanced parameters: Top P, Top K, Min P -->
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider"
style="color: var(--text-secondary);">Top P</label>
<input type="number" x-model.number="modelSettings.top_p" @input="onModelSettingsChange()"
step="0.05" min="0" max="1" placeholder="—"
class="w-full px-2 py-2 text-xs border border-line rounded-xl text-center focus:outline-none focus:border-line-strong bg-surface-muted"
style="color: var(--text-primary);">
</div>
<div class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider"
style="color: var(--text-secondary);">Top K</label>
<input type="number" x-model.number="modelSettings.top_k" @input="onModelSettingsChange()" min="0"
placeholder="—"
class="w-full px-2 py-2 text-xs border border-line rounded-xl text-center focus:outline-none focus:border-line-strong bg-surface-muted"
style="color: var(--text-primary);">
</div>
<div class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider"
style="color: var(--text-secondary);">Min P</label>
<input type="number" x-model.number="modelSettings.min_p" @input="onModelSettingsChange()"
step="0.01" min="0" max="1" placeholder="—"
class="w-full px-2 py-2 text-xs border border-line rounded-xl text-center focus:outline-none focus:border-line-strong bg-surface-muted"
style="color: var(--text-primary);">
</div>
</div>
<!-- Repetition Penalty & Presence Penalty -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider"
style="color: var(--text-secondary);">Rep Penalty</label>
<input type="number" x-model.number="modelSettings.repetition_penalty"
@input="onModelSettingsChange()" step="0.05" min="0" placeholder="Default"
class="w-full px-3 py-2 text-xs border border-line rounded-xl focus:outline-none focus:border-line-strong bg-surface-muted"
style="color: var(--text-primary);">
</div>
<div class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider"
style="color: var(--text-secondary);">Pres Penalty</label>
<input type="number" x-model.number="modelSettings.presence_penalty"
@input="onModelSettingsChange()" step="0.05" min="-2" max="2" placeholder="Default"
class="w-full px-3 py-2 text-xs border border-line rounded-xl focus:outline-none focus:border-line-strong bg-surface-muted"
style="color: var(--text-primary);">
</div>
</div>
<!-- Thinking: Auto / On (Unlimit) / On (Limit) / Off -->
<div class="p-3 bg-surface-muted rounded-xl border border-line space-y-2">
<label class="text-[10px] font-bold uppercase tracking-wider block"
style="color: var(--text-secondary);">Thinking</label>
<select :value="thinkingModeValue()" @change="setThinkingMode($event.target.value)"
class="w-full px-3 py-2 text-xs border border-line rounded-xl bg-surface"
style="color: var(--text-primary);">
<option value="auto">Auto</option>
<option value="on_unlimit">On (Unlimit)</option>
<option value="on_limit">On (Limit)</option>
<option value="off">Off</option>
</select>
<div x-show="thinkingModeValue() === 'on_limit'" x-collapse class="space-y-1">
<label class="text-[10px] font-bold uppercase tracking-wider block"
style="color: var(--text-secondary);">Thinking tokens</label>
<input type="number" x-model.number="modelSettings.thinking_budget_tokens"
@focus="_thinkingBudgetBeforeInput = modelSettings.thinking_budget_tokens"
@keydown.arrow-up.prevent="stepThinkingBudgetTokens(1)"
@keydown.arrow-down.prevent="stepThinkingBudgetTokens(-1)"
@input="onThinkingBudgetTokensInput($event)"
@change="clampThinkingBudgetTokens()"
placeholder="4001" min="1" step="1000"
class="w-full px-3 py-2 text-xs border border-line rounded-xl bg-surface thinking-budget-input"
style="color: var(--text-primary);">
</div>
<p class="text-[10px]" style="color: var(--text-tertiary);">
KV cache quantization and speculative decoding are configured in Admin → Model Settings (requires model reload).
</p>
</div>
</div>
</div>
<!-- Performance Stats Panel on the Bottom -->
<div class="p-3 border-t border-line bg-surface flex-shrink-0">
<div class="grid grid-cols-2 gap-2">
<div class="bg-surface-muted p-2 rounded-xl border border-line flex flex-col justify-between">
<span class="text-[9px] font-bold uppercase tracking-wider"
style="color: var(--text-tertiary);">Prefill (t/s)</span>
<div class="flex items-end justify-between">
<div class="text-sm font-semibold" style="color: var(--text-primary);"
x-text="recentStats?.avg_prefill_tps ? Number(recentStats.avg_prefill_tps).toFixed(1) : '0.0'">
</div>
<div class="text-[10px]" style="color: var(--text-primary);"
x-show="recentStats?.prompt_tokens > 0" x-text="recentStats.prompt_tokens + ' tok'"></div>
</div>
</div>
<div class="bg-surface-muted p-2 rounded-xl border border-line flex flex-col justify-between">
<span class="text-[9px] font-bold uppercase tracking-wider"
style="color: var(--text-tertiary);">Token Gen (t/s)</span>
<div class="flex items-end justify-between">
<div class="text-sm font-semibold" style="color: var(--text-primary);"
x-text="recentStats?.avg_generation_tps ? Number(recentStats.avg_generation_tps).toFixed(1) : '0.0'">
</div>
<div class="text-[10px]" style="color: var(--text-primary);"
x-show="recentStats?.total_tokens > 0" x-text="recentStats.total_tokens + ' tok'"></div>
</div>
</div>
<div class="bg-surface-muted p-2 rounded-xl border border-line flex flex-col justify-between">
<span class="text-[9px] font-bold uppercase tracking-wider"
style="color: var(--text-tertiary);">Thinking (s)</span>
<div class="flex items-end justify-between">
<div class="text-sm font-semibold" style="color: var(--text-primary);"
x-text="recentStats?.thinking_time ? Number(recentStats.thinking_time).toFixed(1) : '-'">
</div>
<div class="text-[10px]" style="color: var(--text-primary);"
x-show="recentStats?.thinking_tokens > 0" x-text="recentStats.thinking_tokens + ' tok'">
</div>
</div>
</div>
<div class="bg-surface-muted p-2 rounded-xl border border-line flex flex-col justify-between">
<span class="text-[9px] font-bold uppercase tracking-wider"
style="color: var(--text-tertiary);">Duration (s)</span>
<div class="flex items-end justify-between">
<div class="text-sm font-semibold" style="color: var(--text-primary);"
x-text="recentStats?.total_time ? Number(recentStats.total_time).toFixed(1) : '-'"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Image Modal -->
<div x-show="imageModal.show" x-cloak class="image-modal" @click="closeImageModal()"
@keydown.escape.window="closeImageModal()">
<button class="image-modal-close" @click="closeImageModal()">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
<img :src="imageModal.src" @click.stop alt="Preview" x-show="imageModal.src">
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Configure marked.js
marked.use({
breaks: true,
gfm: true
});
if (typeof markedHighlight !== 'undefined') {
marked.use(markedHighlight.markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
}));
}
const CHAT_UNTITLED_I18N_KEY = 'chat.untitled_title';
function resolveChatUntitledLabel() {
if (typeof window.t === 'function') {
const translated = window.t(CHAT_UNTITLED_I18N_KEY);
if (translated
&& translated !== CHAT_UNTITLED_I18N_KEY
&& !/^chat\.untitled/.test(translated)) {
return translated;
}
}
return 'Untitled';
}
function chatApp() {
return {
// API Key
apiKeySet: false,
apiKeyInput: '',
// UI State
isMobile: window.innerWidth < 768,
sidebarOpen: window.innerWidth >= 768,
showLeftToggle: window.innerWidth < 768,
theme: localStorage.getItem('omlx-chat-theme') || 'auto',
allowSvg: localStorage.getItem('omlx-chat-allow-svg') === 'true',
activeTheme: 'light', // Will be updated by applyTheme
systemThemeListener: null,
// Chat State
currentChatId: null,
chatHistory: [],
_thinkingDomSeq: 0,
_adminModelsList: null,
_adminModelsListPromise: null,
_thinkingBudgetBeforeInput: null,
_thinkingBudgetStepping: false,
chatSessions: {},
messages: [],
inputMessage: '',
// Model State
availableModels: [],
currentModel: null,
// Streaming State
streamSessions: {},
// Scroll state
autoScrollEnabled: true,
isUserScrolling: false,
scrollTimeout: null,
thinkingAutoScroll: true,
thinkingScrollTimeout: null,
thinkingScrollPosition: 0, // Save scroll position before DOM update
// Edit State
editingIndex: null,
editContent: '',
editImages: [], // Preserve images during edit
renamingChatId: null,
renameDraft: '',
// Image Upload State
uploadImages: [], // Array of { id, base64 } for pending images (max 10)
uploadDocuments: [], // Array of { id, filename, mimeType, base64 }
markitdownSettings: {
enabled: true,
max_file_size_mb: 25,
max_files_per_request: 5,
},
isDragOver: false, // drag-and-drop state
// Image Modal State
imageModal: {
show: false,
src: ''
},
// VLM Detection State
modelTypeMap: {}, // { modelId: "llm"|"vlm"|"embedding"|"reranker" }
aliasToGateway: {}, // { aliasName: gatewayId } maps alias to directory name
// Session Prompt
systemPrompt: '',
// Right Sidebar Tab
rightSidebarTab: 'settings',
// PROFILE Section State
promptProfiles: [],
activePromptProfile: null,
promptDirty: false,
editingPromptProfileName: false,
newProfileNameDraft: '',
profileDeleteConfirm: null,
// MODEL SETTINGS Section State (shared prompt profiles from PROFILE tab)
// MCP Tool Call Limits
MAX_TOOL_DEPTH: 10, // Max recursive streamResponse calls for tool loops
TOOL_TIMEOUT_MS: 30000, // Per-tool execution timeout (ms)
// MCP tools & prefill polling
mcpTools: [], // OpenAI-format tool definitions loaded from /v1/mcp/tools
// Right Sidebar State
rightSidebarOpen: window.innerWidth >= 1200,
showRightToggle: window.innerWidth < 1200,
modelSettingsDirty: false,
speculativeEngine: 'none',
speculativeDraftModel: '',
isMtpCompatible: false,
isVlm: false,
recentStats: {
avg_prefill_tps: 0,
avg_generation_tps: 0,
thinking_time: 0,
total_time: 0
},
statsInterval: null,
// Update check state
updateAvailable: false,
latestVersion: null,
releaseUrl: null,
versionHover: false,
_updateCheckTimer: null,
// Timeline navigation rail
timelineDots: [], // [{ index, topPx }]
timelineActive: -1,
timelineTooltip: { show: false, content: '', x: 0, y: 0 },
modelSettings: {
temperature: null,
max_tokens: null,
top_p: null,
top_k: null,
min_p: null,
repetition_penalty: null,
presence_penalty: null,
enable_thinking: null,
thinking_budget_enabled: false,
thinking_budget_tokens: null,
},
defaultModelSettings() {
return {
temperature: null,
max_tokens: null,
top_p: null,
top_k: null,
min_p: null,
repetition_penalty: null,
presence_penalty: null,
enable_thinking: null,
thinking_budget_enabled: false,
thinking_budget_tokens: null,
};
},
thinkingModeValue() {
if (this.modelSettings.enable_thinking === false) return 'off';
if (this.modelSettings.enable_thinking === true) {
return this.modelSettings.thinking_budget_enabled ? 'on_limit' : 'on_unlimit';
}
return 'auto';
},
normalizeThinkingBudgetTokens(value) {
if (value === '' || value == null) return 4096;
const n = Number(value);
if (!Number.isFinite(n)) return 4096;
if (n <= 0) return 1;
return Math.floor(n);
},
stepThinkingBudgetTokens(direction) {
if (this.thinkingModeValue() !== 'on_limit') return;
const step = 1024;
let v = Number(this.modelSettings.thinking_budget_tokens);
if (!Number.isFinite(v) || v <= 0) v = 0;
if (direction > 0) {
this.modelSettings.thinking_budget_tokens = v <= 0 ? 1 : v + step;
} else if (v <= 1) {
this.modelSettings.thinking_budget_tokens = 1;
} else if (v <= step) {
this.modelSettings.thinking_budget_tokens = 1;
} else {
this.modelSettings.thinking_budget_tokens = v - step;
}
this._thinkingBudgetBeforeInput = this.modelSettings.thinking_budget_tokens;
this.onModelSettingsChange();
},
onThinkingBudgetTokensInput(event) {
if (this._thinkingBudgetStepping) return;
const el = event.target;
const raw = Number(el.value);
const prev = Number(this._thinkingBudgetBeforeInput);
if (Number.isFinite(prev) && Number.isFinite(raw) && raw !== prev) {
const delta = raw - prev;
// Native spinners use (min + n*step); 4096 is off-grid when min=1, step=100/1024
if (delta !== 0 && Math.abs(delta) < 1024) {
this._thinkingBudgetStepping = true;
this.modelSettings.thinking_budget_tokens = prev;
this.stepThinkingBudgetTokens(delta > 0 ? 1 : -1);
el.value = this.modelSettings.thinking_budget_tokens;
this._thinkingBudgetBeforeInput = this.modelSettings.thinking_budget_tokens;
this._thinkingBudgetStepping = false;
return;
}
}
this._thinkingBudgetBeforeInput = raw;
this.modelSettings.thinking_budget_tokens = raw;
this.onModelSettingsChange();
},
clampThinkingBudgetTokens() {
if (this.thinkingModeValue() !== 'on_limit') return;
this.modelSettings.thinking_budget_tokens = this.normalizeThinkingBudgetTokens(
this.modelSettings.thinking_budget_tokens
);
this._thinkingBudgetBeforeInput = this.modelSettings.thinking_budget_tokens;
},
setThinkingMode(mode) {
if (mode === 'on_unlimit') {
this.modelSettings.enable_thinking = true;
this.modelSettings.thinking_budget_enabled = false;
this.modelSettings.thinking_budget_tokens = null;
} else if (mode === 'on_limit') {
this.modelSettings.enable_thinking = true;
this.modelSettings.thinking_budget_enabled = true;
this.modelSettings.thinking_budget_tokens = this.normalizeThinkingBudgetTokens(
this.modelSettings.thinking_budget_tokens
);
} else if (mode === 'off') {
this.modelSettings.enable_thinking = false;
this.modelSettings.thinking_budget_enabled = false;
this.modelSettings.thinking_budget_tokens = null;
} else {
this.modelSettings.enable_thinking = null;
this.modelSettings.thinking_budget_enabled = false;
this.modelSettings.thinking_budget_tokens = null;
}
this.onModelSettingsChange();
},
captureSessionModelSettings() {
if (this.modelSettings.thinking_budget_enabled) {
this.modelSettings.thinking_budget_tokens = this.normalizeThinkingBudgetTokens(
this.modelSettings.thinking_budget_tokens
);
}
return {
modelSettings: this.cloneData(this.modelSettings),
speculativeEngine: this.speculativeEngine,
speculativeDraftModel: this.speculativeDraftModel,
};
},
saveModelSettingsForModel(session, modelId) {
if (!session || !modelId) return;
const key = this.resolveGatewayModelId(modelId);
if (!session.modelSettingsByModel) session.modelSettingsByModel = {};
session.modelSettingsByModel[key] = this.captureSessionModelSettings();
},
loadModelSettingsForModel(session, modelId) {
if (!session || !modelId) return false;
const key = this.resolveGatewayModelId(modelId);
const saved = session.modelSettingsByModel?.[key];
if (!saved) return false;
session.modelSettings = this.cloneData(saved.modelSettings);
session.speculativeEngine = saved.speculativeEngine ?? 'none';
session.speculativeDraftModel = saved.speculativeDraftModel ?? '';
this.applySessionModelSettings(session);
return true;
},
applySessionModelSettings(session) {
if (!session?.modelSettings) return false;
this.modelSettings = { ...this.defaultModelSettings(), ...this.cloneData(session.modelSettings) };
if (this.modelSettings.thinking_budget_enabled) {
this.modelSettings.thinking_budget_tokens = this.normalizeThinkingBudgetTokens(
this.modelSettings.thinking_budget_tokens
);
}
this.speculativeEngine = session.speculativeEngine ?? 'none';
this.speculativeDraftModel = session.speculativeDraftModel ?? '';
this.modelSettingsDirty = false;
return true;
},
syncSessionModelSettingsFromUi(session) {
if (!session) return;
const snap = this.captureSessionModelSettings();
session.modelSettings = snap.modelSettings;
session.speculativeEngine = snap.speculativeEngine;
session.speculativeDraftModel = snap.speculativeDraftModel;
if (session.model) {
this.saveModelSettingsForModel(session, session.model);
}
},
persistChatSettingsToHistory(chatId = this.currentChatId) {
const session = this.getChatSession(chatId, false);
if (!session || !chatId) return;
if (chatId === this.currentChatId) {
this.syncSessionModelSettingsFromUi(session);
}
const existingIndex = this.chatHistory.findIndex(c => c.id === chatId);
if (existingIndex < 0) {
this.saveCurrentChat(chatId);
return;
}
const snap = this.captureSessionModelSettings();
this.chatHistory[existingIndex] = {
...this.chatHistory[existingIndex],
model: session.model ?? this.chatHistory[existingIndex].model,
systemPrompt: session.systemPrompt ?? this.chatHistory[existingIndex].systemPrompt,
activeProfile: session.activeProfile ?? this.chatHistory[existingIndex].activeProfile,
modelSettings: snap.modelSettings,
speculativeEngine: snap.speculativeEngine,
speculativeDraftModel: snap.speculativeDraftModel,
modelSettingsByModel: this.cloneData(session.modelSettingsByModel || {}),
};
this.saveChatHistory();
},
emptyStats() {
return {
avg_prefill_tps: 0,
avg_generation_tps: 0,
thinking_time: 0,
total_time: 0,
prompt_tokens: 0,
thinking_tokens: 0,
total_tokens: 0
};
},
untitledChatTitle() {
return resolveChatUntitledLabel();
},
displayChatTitle(chat) {
const title = chat?.title;
if (this.isPlaceholderChatTitle(title)) {
return this.untitledChatTitle();
}
return title || this.untitledChatTitle();
},
isPlaceholderChatTitle(title) {
if (!title || !String(title).trim()) return true;
const normalized = String(title).trim();
if (/^chat\.untitled/.test(normalized)) return true;
if (normalized === this.untitledChatTitle()) return true;
const legacy = window.t('chat.new_chat_title');
return !!(legacy && legacy !== 'chat.new_chat_title' && normalized === legacy);
},
deriveChatTitleFromMessages(messages) {
for (const msg of messages || []) {
if (msg.role !== 'user') continue;
const text = this.getTextContent(msg.content)?.trim();
if (text) return text.slice(0, 50);
}
return null;
},
resolveAutoChatTitle(messages, existingChat) {
const untitled = this.untitledChatTitle();
if (existingChat?.manualTitle) {
return existingChat.title || untitled;
}
const hasAssistant = (messages || []).some(
m => m.role === 'assistant' && m._ui !== false
);
const derived = this.deriveChatTitleFromMessages(messages);
if (hasAssistant && derived && this.isPlaceholderChatTitle(existingChat?.title)) {
return derived;
}
if (!this.isPlaceholderChatTitle(existingChat?.title)) {
return existingChat.title;
}
return untitled;
},
cloneData(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
},
newMessageId() {
return 'msg_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 9);
},
ensureMessageIds(messages) {
if (!messages) return;
for (const msg of messages) {
if (!msg.id) msg.id = this.newMessageId();
}
},
resolveGatewayModelId(modelId) {
if (!modelId) return modelId;
return this.aliasToGateway[modelId] || modelId;
},
isModelAvailableOnServer(modelId) {
if (!modelId || !this.availableModels?.length) return false;
const gatewayId = this.resolveGatewayModelId(modelId);
return this.availableModels.some(m =>
m.id === gatewayId
|| m.id === modelId
|| m.name === modelId
|| m.name === gatewayId
);
},
variantModelMissing(variant) {
const modelRef = variant?.meta?.modelId || variant?.model;
return Boolean(modelRef) && !this.isModelAvailableOnServer(modelRef);
},
resolveDefaultAvailableModel() {
if (this.currentModel && this.isModelAvailableOnServer(this.currentModel)) {
return this.resolveGatewayModelId(this.currentModel);
}
if (this.availableModels?.length) {
return this.availableModels[0].id;
}
return null;
},
/** Collapse duplicate gateway ids (aliases + duplicate /v1/models rows). */
dedupeAvailableModels(models) {
const byId = new Map();
for (const m of models) {
const existing = byId.get(m.id);
if (!existing) {
byId.set(m.id, { ...m });
continue;
}
// Prefer a human-friendly alias label over the raw directory id.
if (m.name && m.name !== m.id && existing.name === existing.id) {
existing.name = m.name;
}
}
return Array.from(byId.values()).sort((a, b) => {
// Favorites always sort first; names decide within each group.
const favDiff = (b.is_favorite ? 1 : 0) - (a.is_favorite ? 1 : 0);
if (favDiff !== 0) return favDiff;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
},
resolvePreferredDefaultModel(defaultModel) {
if (!this.availableModels?.length) return null;
if (defaultModel) {
const candidate = this.resolveGatewayModelId(defaultModel);
if (this.isModelAvailableOnServer(candidate)) {
return candidate;
}
}
return this.availableModels[0].id;
},
reconcileCurrentModelAfterLoad(defaultModel) {
if (!this.availableModels?.length) {
this.currentModel = null;
return;
}
if (!this.currentModel || !this.isModelAvailableOnServer(this.currentModel)) {
this.currentModel = this.resolvePreferredDefaultModel(defaultModel);
} else {
this.currentModel = this.resolveGatewayModelId(this.currentModel);
}
},
/** Keep messages through the user turn that precedes endIndex (drops assistant + tool rows). */
sliceToUserTurn(messages, endIndex) {
let userIdx = -1;
const limit = Math.min(endIndex, messages.length);
for (let i = limit - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
userIdx = i;
break;
}
}
if (userIdx < 0) return messages.slice(0, endIndex);
return messages.slice(0, userIdx + 1);
},
/** Delete/edit slice: user row drops from index; assistant drops back to preceding user. */
sliceBeforeMessage(messages, index) {
const msg = messages[index];
if (!msg) return messages;
if (msg.role === 'user') return messages.slice(0, index);
return this.sliceToUserTurn(messages, index);
},
splitTurnSegments(turnMessages) {
const segments = [];
let current = [];
for (const m of turnMessages) {
current.push(m);
if (m.role === 'assistant' && m._ui !== false) {
segments.push(current);
current = [];
}
}
if (current.length) segments.push(current);
return segments.length ? segments : [turnMessages];
},
getActiveVariantChain(turnMessages, activeVariantId) {
if (!turnMessages.length) return [];
const segments = this.splitTurnSegments(turnMessages);
if (segments.length <= 1) return turnMessages;
if (activeVariantId) {
const match = segments.find(s => s.some(m => m.id === activeVariantId));
if (match) return match;
}
return segments[segments.length - 1];
},
getVariantChainFrom(turnMessages, variantId) {
if (!turnMessages.length || !variantId) return [];
const start = turnMessages.findIndex(m => m.id === variantId);
if (start < 0) return this.getActiveVariantChain(turnMessages, variantId);
const chain = [];
for (let i = start; i < turnMessages.length; i++) {
const m = turnMessages[i];
if (i > start && m.role === 'assistant' && m._ui !== false) break;
chain.push(m);
}
return chain;
},
getApiTurnMessages(messages, excludeVariantsAtUserIndex = null, regenChainId = null) {
const result = [];
let i = 0;
while (i < messages.length) {
const msg = messages[i];
if (msg.role !== 'user') {
i++;
continue;
}
const userIdx = i;
result.push(msg);
i++;
const turn = [];
while (i < messages.length && messages[i].role !== 'user') {
turn.push(messages[i]);
i++;
}
if (userIdx === excludeVariantsAtUserIndex) {
if (regenChainId) {
result.push(...this.getVariantChainFrom(turn, regenChainId));
}
} else {
result.push(...this.getActiveVariantChain(turn, msg._activeVariantId));
}
}
return result;
},
findUserIndexForMessage(messages, msgIndex) {
for (let i = msgIndex; i >= 0; i--) {
if (messages[i]?.role === 'user') return i;
}
return -1;
},
getTurnVariantsAt(messages, userIndex) {
if (!messages[userIndex] || messages[userIndex].role !== 'user') return [];
const variants = [];
for (let i = userIndex + 1; i < messages.length && messages[i].role !== 'user'; i++) {
const m = messages[i];
if (m.role === 'assistant' && m._ui !== false) variants.push(m);
}
return variants;
},
getTurnVariantsForUser(userIndex) {
return this.getTurnVariantsAt(this.messages, userIndex);
},
getActiveVariantForUser(messages, userIndex) {
const variants = [];
for (let i = userIndex + 1; i < messages.length && messages[i].role !== 'user'; i++) {
const m = messages[i];
if (m.role === 'assistant' && m._ui !== false) variants.push(m);
}
if (!variants.length) return null;
const activeId = messages[userIndex]._activeVariantId;
if (activeId) {
return variants.find(v => v.id === activeId) || variants[variants.length - 1];
}
return variants[variants.length - 1];
},
isMessageVisible(msg, index) {
if (msg._ui === false) return false;
if (msg.role !== 'assistant') return true;
const userIdx = this.findUserIndexForMessage(this.messages, index);
if (userIdx < 0) return true;
const active = this.getActiveVariantForUser(this.messages, userIdx);
return active ? msg.id === active.id : true;
},
isActiveVariantForUser(userIndex, variantId) {
const user = this.messages[userIndex];
if (!user) return false;
const activeId = user._activeVariantId;
const variants = this.getTurnVariantsForUser(userIndex);
if (!variants.length) return false;
if (activeId) return activeId === variantId;
return variants[variants.length - 1].id === variantId;
},
applyVariantStats(variant) {
if (variant?._recentStats) {
this.recentStats = { ...variant._recentStats };
} else {
this.recentStats = this.emptyStats();
}
},
syncFooterStatsFromMessages(messages) {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role !== 'user') continue;
const variant = this.getActiveVariantForUser(messages, i);
if (variant) {
this.applyVariantStats(variant);
return;
}
}
this.recentStats = this.emptyStats();
},
isVariantTabStreaming(userIndex, variantId) {
const stream = this.getStreamSession(this.currentChatId, false);
return Boolean(
stream?.isStreaming
&& stream.targetMessageId
&& stream.targetMessageId === variantId
);
},
async selectVariant(userIndex, variantId) {
const session = this.getChatSession(this.currentChatId, true);
if (!session?.messages[userIndex] || session.messages[userIndex].role !== 'user') return;
session.messages[userIndex]._activeVariantId = variantId;
this.messages = session.messages;
const variant = session.messages.find(m => m.id === variantId);
this.applyVariantStats(variant);
await this.applyVariantPanelSnapshot(variant, session);
this.saveCurrentChat(this.currentChatId);
this.scheduleEnhanceMessages();
},
applyGenerationSnapshot(generation) {
const s = { ...this.defaultModelSettings() };
if (!generation || typeof generation !== 'object') {
this.modelSettings = s;
return;
}
const scalarKeys = [
'temperature', 'max_tokens', 'top_p', 'top_k', 'min_p',
'repetition_penalty', 'presence_penalty',
];
for (const key of scalarKeys) {
if (generation[key] != null) s[key] = generation[key];
}
const ctk = generation.chat_template_kwargs;
if (ctk && typeof ctk.enable_thinking === 'boolean') {
s.enable_thinking = ctk.enable_thinking;
} else if (generation.enable_thinking === true) {
s.enable_thinking = true;
} else if (generation.enable_thinking === false) {
s.enable_thinking = false;
} else if (generation.thinking_budget != null) {
s.enable_thinking = true;
}
const budget = generation.thinking_budget ?? generation.thinking_budget_tokens;
if (budget != null) {
s.enable_thinking = true;
s.thinking_budget_enabled = true;
s.thinking_budget_tokens = this.normalizeThinkingBudgetTokens(budget);
} else if (s.enable_thinking === true) {
s.thinking_budget_enabled = false;
s.thinking_budget_tokens = null;
} else {
s.thinking_budget_enabled = false;
s.thinking_budget_tokens = null;
}
this.modelSettings = s;
},
async applyVariantPanelSnapshot(variant, session) {
if (!variant || !session) return;
const meta = variant.meta || {};
const modelMissing = this.variantModelMissing(variant);
if (modelMissing) {
const fallbackModel = this.resolveDefaultAvailableModel();
if (fallbackModel) {
this.currentModel = fallbackModel;
session.model = fallbackModel;
session.modelSettings = null;
session.speculativeEngine = null;
session.speculativeDraftModel = null;
await this.ensureSessionModelSettings(session, fallbackModel);
this.saveModelSettingsForModel(session, fallbackModel);
}
} else {
if (meta.modelId) {
const gatewayId = this.resolveGatewayModelId(meta.modelId);
this.currentModel = gatewayId;
session.model = gatewayId;
}
if (meta.generation && Object.keys(meta.generation).length > 0) {
this.applyGenerationSnapshot(meta.generation);
} else if (meta.modelId) {
const gatewayId = this.resolveGatewayModelId(meta.modelId);
await this.loadAdminModelDefaults(gatewayId);
}
}
if (meta.profileName) {
const name = this.normalizeProfileName(meta.profileName);
if (name && this.promptProfiles.some(p => p.name === name)) {
this.activePromptProfile = name;
session.activeProfile = name;
}
}
if (meta.systemPrompt != null && String(meta.systemPrompt).trim()) {
this.systemPrompt = String(meta.systemPrompt).trim();
session.systemPrompt = this.systemPrompt;
}
this.syncSessionModelSettingsFromUi(session);
this.modelSettingsDirty = false;
this.persistChatSettingsToHistory(this.currentChatId);
if (this.currentModel) {
await this.loadModelCapabilities(this.currentModel);
}
},
variantTabLabel(variant, index) {
const meta = variant.meta || {};
const name = meta.modelDisplay || variant.model || `Response ${index + 1}`;
return name.length > 28 ? name.slice(0, 26) + '…' : name;
},
variantModelLabel(msg) {
const meta = msg.meta || {};
return meta.modelDisplay || msg.model || this.currentModel
|| window.t('chat.assistant_label');
},
normalizeProfileName(name) {
const n = String(name ?? '').trim();
if (!n || n === 'System Default') return null;
return n;
},
resolveStreamProfile(streamContext = null, session = null) {
const chatSession = session || this.getChatSession(
streamContext?.chatId || this.currentChatId, false
);
return this.normalizeProfileName(streamContext?._profile)
|| this.normalizeProfileName(this.activePromptProfile)
|| this.normalizeProfileName(chatSession?.activeProfile)
|| null;
},
resolveMessageProfile(msg) {
return this.normalizeProfileName(msg?._profile)
|| this.normalizeProfileName(msg?.meta?.profileName)
|| null;
},
variantProfileLabel(msg) {
return this.resolveMessageProfile(msg) || '';
},
attachProfileToAssistantMessage(msg, profileLabel) {
const name = this.normalizeProfileName(profileLabel);
if (name) {
msg._profile = name;
if (!msg.meta) msg.meta = {};
msg.meta.profileName = name;
}
return msg;
},
attachThinkingToAssistantMessage(msg, thinking) {
const content = thinking || null;
msg._thinking = content;
msg.reasoning_content = content;
return msg;
},
snapshotGenerationSettings() {
const gen = {};
const s = this.modelSettings;
const scalarKeys = [
'temperature', 'max_tokens', 'top_p', 'top_k', 'min_p',
'repetition_penalty', 'presence_penalty'
];
for (const key of scalarKeys) {
if (s[key] != null) gen[key] = s[key];
}
if (s.enable_thinking === true) {
gen.chat_template_kwargs = { enable_thinking: true };
if (s.thinking_budget_enabled) {
gen.thinking_budget = this.normalizeThinkingBudgetTokens(s.thinking_budget_tokens);
}
} else if (s.enable_thinking === false) {
gen.chat_template_kwargs = { enable_thinking: false };
}
return Object.keys(gen).length ? gen : null;
},
captureGenerationContext(modelId, systemPrompt, profile) {
const gatewayId = this.resolveGatewayModelId(modelId || this.currentModel);
const displayModel = this.availableModels.find(m => m.id === gatewayId)
|| this.availableModels.find(m => m.name === modelId);
const profileName = this.normalizeProfileName(profile)
|| this.normalizeProfileName(this.activePromptProfile);
return {
modelId: gatewayId,
modelDisplay: displayModel?.name || modelId || gatewayId,
profileName,
systemPrompt: systemPrompt ?? '',
generation: this.snapshotGenerationSettings(),
capturedAt: new Date().toISOString(),
};
},
resolveApiSystemPrompt(messages, fallback, variantUserIndex = null) {
if (variantUserIndex != null && messages[variantUserIndex]?.role === 'user') {
const variantsAtTurn = this.getTurnVariantsAt(messages, variantUserIndex);
if (variantsAtTurn.length > 0) {
if (fallback?.trim()) return fallback.trim();
const variant = this.getActiveVariantForUser(messages, variantUserIndex);
const sp = variant?.meta?.systemPrompt;
if (sp != null && String(sp).trim()) return String(sp).trim();
return '';
}
for (let i = variantUserIndex - 1; i >= 0; i--) {
if (messages[i].role !== 'user') continue;
const variant = this.getActiveVariantForUser(messages, i);
const sp = variant?.meta?.systemPrompt;
if (sp != null && String(sp).trim()) return String(sp).trim();
break;
}
return fallback?.trim() ? fallback.trim() : '';
}
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role !== 'user') continue;
if (!this.getTurnVariantsAt(messages, i).length) continue;
const variant = this.getActiveVariantForUser(messages, i);
const sp = variant?.meta?.systemPrompt;
if (sp != null && String(sp).trim()) return String(sp).trim();
break;
}
return fallback?.trim() ? fallback.trim() : '';
},
resolveApiModel(messages, fallbackModel, variantUserIndex = null) {
const fallback = this.resolveGatewayModelId(fallbackModel);
if (variantUserIndex != null) {
if (this.getTurnVariantsAt(messages, variantUserIndex).length > 0) {
return fallback;
}
for (let i = variantUserIndex - 1; i >= 0; i--) {
if (messages[i].role !== 'user') continue;
const variant = this.getActiveVariantForUser(messages, i);
if (variant?.meta?.modelId) {
return this.resolveGatewayModelId(variant.meta.modelId);
}
break;
}
return fallback;
}
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role !== 'user') continue;
if (!this.getTurnVariantsAt(messages, i).length) continue;
const variant = this.getActiveVariantForUser(messages, i);
if (variant?.meta?.modelId) {
return this.resolveGatewayModelId(variant.meta.modelId);
}
break;
}
return fallback;
},
resolveApiGenerationOverrides(messages, variantUserIndex = null) {
const fromMeta = (variant) => {
const gen = variant?.meta?.generation;
return gen && typeof gen === 'object' ? { ...gen } : {};
};
if (variantUserIndex != null) {
if (this.getTurnVariantsAt(messages, variantUserIndex).length > 0) {
return { ...(this.snapshotGenerationSettings() || {}) };
}
for (let i = variantUserIndex - 1; i >= 0; i--) {
if (messages[i].role !== 'user') continue;
const variant = this.getActiveVariantForUser(messages, i);
if (variant) return fromMeta(variant);
break;
}
return { ...(this.snapshotGenerationSettings() || {}) };
}
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role !== 'user') continue;
if (!this.getTurnVariantsAt(messages, i).length) continue;
const variant = this.getActiveVariantForUser(messages, i);
if (variant) return fromMeta(variant);
break;
}
return { ...(this.snapshotGenerationSettings() || {}) };
},
buildContentForApi(content) {
if (!Array.isArray(content)) return content ?? null;
const parts = [];
for (const part of content) {
if (part?.type !== 'file') {
parts.push(part);
continue;
}
const file = part.file || {};
const data = file.file_data || file.data;
if (typeof data !== 'string' || !data.trim()) {
continue;
}
parts.push(part);
}
return parts.length ? parts : '';
},
buildMessagesForApi(messages, systemPrompt, opts = {}) {
const variantUserIndex = opts.variantUserIndex ?? null;
const excludeVariantsAtUserIndex = opts.excludeVariantsAtUserIndex ?? null;
const regenChainId = opts.regenChainId ?? null;
const apiSystemPrompt = this.resolveApiSystemPrompt(
messages, systemPrompt, variantUserIndex
);
const out = this.getApiTurnMessages(messages, excludeVariantsAtUserIndex, regenChainId)
.filter(msg => ['user', 'assistant', 'tool', 'system'].includes(msg.role))
.map(msg => {
const m = { role: msg.role, content: this.buildContentForApi(msg.content) };
if (msg.reasoning_content) {
m.reasoning_content = msg.reasoning_content;
}
if (!m.reasoning_content && msg.role === 'assistant' && msg._thinking) {
m.reasoning_content = msg._thinking;
}
if (msg.tool_calls) m.tool_calls = msg.tool_calls;
if (msg.tool_call_id) m.tool_call_id = msg.tool_call_id;
return m;
});
if (apiSystemPrompt) {
out.unshift({ role: 'system', content: apiSystemPrompt });
}
return out;
},
buildChatCompletionBody(messages, context, depth = 0) {
const variantUserIndex = context._variantUserIndex ?? null;
const excludeVariantsAtUserIndex = context._excludeVariantsAtUserIndex ?? null;
const regenChainId = excludeVariantsAtUserIndex != null && depth > 0
? (context._apiVariantChainId ?? null)
: null;
const model = this.resolveApiModel(messages, context.model, variantUserIndex);
const generation = this.resolveApiGenerationOverrides(messages, variantUserIndex);
return {
model,
messages: this.buildMessagesForApi(messages, context.systemPrompt, {
variantUserIndex,
excludeVariantsAtUserIndex,
regenChainId,
}),
stream: true,
stream_options: { include_usage: true },
...generation,
...(this.mcpTools.length > 0 && { tools: this.mcpTools }),
};
},
createThinkingState() {
return {
isInThinking: false,
thinkingStartTime: null
};
},
createStreamSession(chatId = null) {
return {
chatId,
sourceModel: null,
sourceSystemPrompt: '',
sourceProfile: null,
isStreaming: false,
streamingContent: '',
abortController: null,
thinkingState: this.createThinkingState(),
streamingThinking: '',
streamingThinkingOpen: false,
streamRenderState: {
stableIndex: 0,
stableText: ''
},
engineStatus: null,
statusLog: [],
finalContent: false,
statusTimelineVisible: false,
_statusStart: 0,
_toolTimers: {},
_prefillInterval: null,
targetMessageId: null,
_statsRequestId: null,
_liveFooterStats: null,
};
},
resolveStreamModelId(chatId, stream) {
const session = this.getChatSession(chatId, false);
return this.resolveGatewayModelId(
session?.model || stream?.sourceModel || this.currentModel
);
},
findModelStatsEntry(data, modelId) {
if (!data?.active_models?.models || !modelId) return null;
const gatewayId = this.resolveGatewayModelId(modelId);
return data.active_models.models.find(m =>
m.id === gatewayId || m.id === modelId
) || null;
},
claimStatsRequests(data) {
const claimed = new Set();
for (const stream of Object.values(this.streamSessions)) {
if (stream?._statsRequestId) claimed.add(stream._statsRequestId);
}
const pending = Object.entries(this.streamSessions)
.filter(([, stream]) => stream?.isStreaming)
.sort((a, b) => (a[1]._statusStart || 0) - (b[1]._statusStart || 0));
for (const [chatId, stream] of pending) {
const modelId = this.resolveStreamModelId(chatId, stream);
const modelData = this.findModelStatsEntry(data, modelId);
if (!modelData) continue;
const isActive = (rid) => {
if (!rid) return false;
return (modelData.prefilling || []).some(p => p.request_id === rid)
|| (modelData.generating || []).some(g => g.request_id === rid);
};
if (stream._statsRequestId && isActive(stream._statsRequestId)) {
claimed.add(stream._statsRequestId);
continue;
}
stream._statsRequestId = null;
const pick = (modelData.generating || []).find(g =>
g.request_id && !claimed.has(g.request_id)
) || (modelData.prefilling || []).find(p =>
p.request_id && !claimed.has(p.request_id)
);
if (pick?.request_id) {
stream._statsRequestId = pick.request_id;
claimed.add(pick.request_id);
}
}
},
getClaimedRequestStats(stream, modelData) {
if (!stream?._statsRequestId || !modelData) return null;
const rid = stream._statsRequestId;
const generating = (modelData.generating || []).find(g => g.request_id === rid);
if (generating) {
return {
phase: 'generating',
generated_tokens: generating.generated_tokens || 0,
tokens_per_second: generating.tokens_per_second || 0,
prompt_tokens: generating.prompt_tokens || 0,
};
}
const prefilling = (modelData.prefilling || []).find(p => p.request_id === rid);
if (prefilling) {
const speed = prefilling.speed
|| (prefilling.processed && prefilling.total
? (prefilling.processed / Math.max(prefilling.total, 1)) * 100
: 0);
return {
phase: 'prefilling',
speed,
prompt_tokens: prefilling.prompt_tokens || 0,
};
}
return null;
},
computeLiveFooterStats(chatId, stream, data) {
const modelId = this.resolveStreamModelId(chatId, stream);
const modelData = this.findModelStatsEntry(data, modelId);
const claimed = this.getClaimedRequestStats(stream, modelData);
const base = { ...(stream._liveFooterStats || this.emptyStats()) };
if (!claimed) {
return {
...base,
total_time: stream._statusStart
? ((Date.now() - stream._statusStart) / 1000).toFixed(1)
: base.total_time,
};
}
if (claimed.phase === 'prefilling') {
base.avg_prefill_tps = claimed.speed || 0;
if (claimed.prompt_tokens > 0) base.prompt_tokens = claimed.prompt_tokens;
} else {
base.avg_generation_tps = claimed.tokens_per_second || 0;
if (claimed.prompt_tokens > 0) base.prompt_tokens = claimed.prompt_tokens;
if (claimed.generated_tokens > 0) base.total_tokens = claimed.generated_tokens;
if (claimed.prompt_tokens > 0 && stream._ttft > 0) {
base.avg_prefill_tps = claimed.prompt_tokens / stream._ttft;
}
}
if (stream._statusStart) {
base.total_time = ((Date.now() - stream._statusStart) / 1000).toFixed(1);
}
if (stream.thinkingState?.isInThinking && stream.thinkingState.thinkingStartTime) {
base.thinking_time = ((Date.now() - stream.thinkingState.thinkingStartTime) / 1000).toFixed(1);
if (claimed.phase === 'generating' && claimed.generated_tokens > 0) {
stream._thinkTokens = claimed.generated_tokens;
base.thinking_tokens = claimed.generated_tokens;
}
} else if (stream._thinkSecs) {
base.thinking_time = stream._thinkSecs;
base.thinking_tokens = stream._thinkTokens || 0;
}
return base;
},
getChatSession(chatId = this.currentChatId, create = false) {
if (!chatId) return null;
if (!this.chatSessions[chatId] && create) {
const historyChat = this.chatHistory.find(c => c.id === chatId);
const loadedMessages = this.cloneData(historyChat?.messages || (this.currentChatId === chatId ? this.messages : [])) || [];
this.ensureMessageIds(loadedMessages);
this.chatSessions[chatId] = {
messages: loadedMessages,
model: historyChat?.model || (this.currentChatId === chatId ? this.currentModel : null),
systemPrompt: historyChat?.systemPrompt ?? (this.currentChatId === chatId
? this.systemPrompt
: (localStorage.getItem('omlx_chat_system_prompt') || '')),
activeProfile: historyChat?.activeProfile ?? null,
modelSettings: historyChat?.modelSettings
? this.cloneData(historyChat.modelSettings) : null,
speculativeEngine: historyChat?.speculativeEngine ?? null,
speculativeDraftModel: historyChat?.speculativeDraftModel ?? null,
modelSettingsByModel: historyChat?.modelSettingsByModel
? this.cloneData(historyChat.modelSettingsByModel) : {},
};
}
return this.chatSessions[chatId] || null;
},
getStreamSession(chatId = this.currentChatId, create = false) {
if (!chatId) return null;
if (!this.streamSessions[chatId] && create) {
this.streamSessions[chatId] = this.createStreamSession(chatId);
}
return this.streamSessions[chatId] || null;
},
currentStream() {
return this.getStreamSession(this.currentChatId, false);
},
isChatStreaming(chatId = this.currentChatId) {
return Boolean(this.getStreamSession(chatId, false)?.isStreaming);
},
anyChatStreaming() {
return Object.values(this.streamSessions).some(s => s?.isStreaming);
},
isCurrentChatStreaming() {
return this.isChatStreaming(this.currentChatId);
},
stopStatsPollingIfIdle() {
if (!this.anyChatStreaming()) {
this.stopStatsPolling();
}
},
buildStreamRecentStats(stream, totalTime) {
const usage = stream?._lastUsage || {};
return {
avg_prefill_tps: usage.prompt_tokens_per_second ?? 0,
avg_generation_tps: usage.generation_tokens_per_second ?? 0,
thinking_time: stream?._thinkSecs || 0,
total_time: totalTime,
prompt_tokens: usage.prompt_tokens ?? 0,
thinking_tokens: stream?._thinkTokens || 0,
total_tokens: usage.completion_tokens ?? 0,
};
},
stopChatStreaming(chatId) {
const stream = this.getStreamSession(chatId, false);
if (stream?.abortController) {
stream.abortController.abort();
}
},
ensureStatsPollingForCurrentChat() {
if (!this.isCurrentChatStreaming()) return;
if (this.statsInterval) {
this.fetchStats();
return;
}
this.fetchStats();
this.statsInterval = setInterval(() => this.fetchStats(), 200);
},
toggleCurrentStreamTimeline() {
const stream = this.currentStream();
if (stream) {
stream.statusTimelineVisible = !stream.statusTimelineVisible;
}
},
toggleCurrentStreamThinking() {
const stream = this.currentStream();
if (stream) {
stream.streamingThinkingOpen = !stream.streamingThinkingOpen;
}
},
resetStreamSession(stream, { preserveStatusLog = false, preserveFinalContent = false } = {}) {
if (!stream) return;
if (stream._prefillInterval) {
clearInterval(stream._prefillInterval);
stream._prefillInterval = null;
}
stream.isStreaming = false;
stream.streamingContent = '';
stream.abortController = null;
stream.thinkingState = this.createThinkingState();
stream.streamingThinking = '';
stream.streamingThinkingOpen = false;
stream.streamRenderState = {
stableIndex: 0,
stableText: ''
};
stream.engineStatus = null;
if (!preserveStatusLog) {
stream.statusLog = [];
stream.statusTimelineVisible = false;
stream._statusStart = 0;
stream._thinkSecs = 0;
stream._ttft = 0;
}
if (!preserveFinalContent) {
stream.finalContent = false;
}
stream._toolTimers = {};
stream.targetMessageId = null;
stream._statsRequestId = null;
stream._liveFooterStats = null;
if (stream._domUpdateRaf) {
cancelAnimationFrame(stream._domUpdateRaf);
stream._domUpdateRaf = null;
}
},
stopAllStreams() {
Object.values(this.streamSessions).forEach((stream) => {
stream.abortController?.abort();
this.resetStreamSession(stream);
});
},
async init() {
this.loadPromptProfiles();
const serverApiKey = {{ api_key | tojson }};
if (serverApiKey) {
localStorage.setItem('omlx_chat_api_key', serverApiKey);
}
const storedKey = localStorage.getItem('omlx_chat_api_key');
if (storedKey) {
this.apiKeySet = true;
await this.loadModels();
if (this.currentModel) {
await this.loadModelCapabilities(this.currentModel);
}
this.loadChatHistory();
this.fetchMcpTools();
}
this.applyTheme();
this.checkForUpdate();
this.startUpdateCheckTimer();
this.$nextTick(() => {
this.setupScrollListener();
this.computeTimelineDots();
this.computeTimelineActive();
});
this.$watch('rightSidebarOpen', () => {
this.$nextTick(() => this.computeTimelineDots());
clearTimeout(this._sidebarTransitionTimer);
this._sidebarTransitionTimer = setTimeout(() => this.computeTimelineDots(), 260);
});
this.$watch('sidebarOpen', (val) => {
clearTimeout(this._sidebarShowTimer);
if (val) {
this.showLeftToggle = false;
} else {
this._sidebarShowTimer = setTimeout(() => { this.showLeftToggle = true; }, 260);
}
});
this.$watch('rightSidebarOpen', (val) => {
clearTimeout(this._rightSidebarShowTimer);
if (val) {
this.showRightToggle = false;
} else {
this._rightSidebarShowTimer = setTimeout(() => { this.showRightToggle = true; }, 260);
}
});
window.addEventListener('resize', () => {
clearTimeout(this._timelineResizeTimer);
this._timelineResizeTimer = setTimeout(() => {
this.computeTimelineDots();
}, 200);
});
// Refresh model list when returning to chat (e.g. after HF download on dashboard).
this._modelsVisibilityTimer = null;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible' || !this.apiKeySet) return;
clearTimeout(this._modelsVisibilityTimer);
this._modelsVisibilityTimer = setTimeout(async () => {
try {
await this.loadModels();
if (this.currentModel) {
await this.loadModelCapabilities(this.currentModel);
}
} catch (e) {
console.error('Failed to refresh models on visibility:', e);
}
}, 300);
});
},
// ===== VLM Detection =====
async fetchModelTypes() {
try {
const resp = await fetch('/v1/models/status', {
headers: { 'Authorization': `Bearer ${this.getApiKey()}` }
});
if (!resp.ok) return;
const data = await resp.json();
const map = {};
for (const m of (data.models || [])) {
map[m.id] = m.model_type || 'llm';
}
this.modelTypeMap = map;
this._mirrorAliasModelTypes();
} catch (e) {
// Model status endpoint unavailable
}
},
_mirrorAliasModelTypes() {
for (const [alias, gatewayId] of Object.entries(this.aliasToGateway)) {
const t = this.modelTypeMap[gatewayId];
if (t && !this.modelTypeMap[alias]) {
this.modelTypeMap[alias] = t;
}
}
},
hasVisionSupport() {
const id = this.resolveGatewayModelId(this.currentModel);
return this.modelTypeMap[id] === 'vlm';
},
hasDocumentSupport() {
return Boolean(this.currentModel && this.markitdownSettings.enabled);
},
hasPendingUploads() {
return this.uploadImages.some(img => !img.base64)
|| this.uploadDocuments.some(doc => !doc.base64);
},
// ===== MCP Tools =====
async fetchMcpTools() {
try {
const resp = await fetch('/v1/mcp/tools', {
headers: { 'Authorization': `Bearer ${this.getApiKey()}` }
});
if (!resp.ok) return;
const data = await resp.json();
this.mcpTools = (data.tools || []).map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description || '',
parameters: t.parameters || { type: 'object', properties: {} }
}
}));
} catch (e) { /* MCP not configured */ }
},
// ===== Engine Status =====
setEngineStatus(chatId, status, { log = true } = {}) {
const stream = this.getStreamSession(chatId, true);
stream.engineStatus = status;
if (log && status) {
const elapsed = ((Date.now() - stream._statusStart) / 1000).toFixed(1);
const last = stream.statusLog[stream.statusLog.length - 1];
if (!last || last.text !== status.text || last.icon !== (status.icon || null)) {
stream.statusLog.push({ t: `+${elapsed}s`, text: status.text, icon: status.icon || null });
}
}
},
startPrefillPolling(chatId) {
const stream = this.getStreamSession(chatId, true);
this.stopPrefillPolling(chatId);
stream._prefillInterval = setInterval(() => this._pollPrefill(chatId), 500);
},
stopPrefillPolling(chatId) {
const stream = this.getStreamSession(chatId, false);
if (stream?._prefillInterval) {
clearInterval(stream._prefillInterval);
stream._prefillInterval = null;
}
},
async _pollPrefill(chatId) {
const stream = this.getStreamSession(chatId, false);
if (!stream?.isStreaming) return;
const session = this.getChatSession(chatId, false);
const modelId = stream.sourceModel || session?.model;
if (!modelId) return;
try {
const resp = await fetch('/admin/api/stats', { credentials: 'same-origin' });
if (!resp.ok) return;
const data = await resp.json();
if (this.anyChatStreaming()) {
this.claimStatsRequests(data);
}
const models = data.active_models?.models || [];
const gatewayId = this.resolveGatewayModelId(modelId);
const model = models.find(m => m.id === gatewayId || m.id === modelId);
if (stream.engineStatus?.icon === 'wrench') return; // don't overwrite tool status
if (model?.is_loading || (!model && models.some(m => m.is_loading))) {
this.setEngineStatus(chatId, { text: 'Loading model' });
} else if (model?.prefilling?.length > 0) {
const p = (stream._statsRequestId
? model.prefilling.find(entry => entry.request_id === stream._statsRequestId)
: null) || model.prefilling[0];
if (p.total > 0) {
const pct = Math.round(p.processed / p.total * 100);
if (pct >= 100) {
stream.engineStatus = { text: 'Generating' }; // prefill done, now generating
} else {
const speed = p.speed > 0 ? ` · ${Math.round(p.speed)} tok/s` : '';
stream.engineStatus = { text: `Prefilling ${pct}%${speed}` };
}
} else {
stream.engineStatus = { text: 'Prefilling' };
}
} else if (model?.generating?.length > 0) {
this.setEngineStatus(chatId, { text: 'Generating' });
}
} catch (e) { /* ignore poll errors */ }
},
// ===== Image Modal Functions =====
openImageModal(url) {
if (!url) return;
this.imageModal = { show: true, src: url };
},
closeImageModal() {
this.imageModal = { show: false, src: '' };
},
// ===== Image Upload Functions =====
handleDrop(event) {
const files = event.dataTransfer?.files;
if (!files) return;
Array.from(files).forEach(file => {
if (this.hasVisionSupport() && file.type.startsWith('image/')) {
this.loadImageFile(file);
} else if (this.hasDocumentSupport() && this.isSupportedDocumentFile(file)) {
this.loadDocumentFile(file);
}
});
},
handleImageSelect(event) {
const files = event.target.files;
if (!files) return;
Array.from(files)
.filter(f => f.type.startsWith('image/'))
.forEach(f => this.loadImageFile(f));
event.target.value = '';
},
loadImageFile(file) {
if (!file.type.startsWith('image/')) {
alert(window.t('chat.error.invalid_image_type'));
return;
}
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
alert(window.t('chat.error.image_too_large'));
return;
}
if (this.uploadImages.length >= 10) return;
const id = `img_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
this.uploadImages.push({ id, base64: null, _pending: true });
const reader = new FileReader();
reader.onload = (e) => {
const entry = this.uploadImages.find(img => img.id === id);
if (!entry?._pending) return;
entry.base64 = e.target.result;
delete entry._pending;
};
reader.onerror = () => {
const idx = this.uploadImages.findIndex(img => img.id === id);
if (idx >= 0) this.uploadImages.splice(idx, 1);
alert(window.t('chat.error.image_load_failed'));
};
reader.readAsDataURL(file);
},
removeImage(index) {
this.uploadImages.splice(index, 1);
},
handleDocumentSelect(event) {
const files = event.target.files;
if (!files) return;
Array.from(files).forEach(file => this.loadDocumentFile(file));
event.target.value = '';
},
isSupportedDocumentFile(file) {
const name = (file.name || '').toLowerCase();
const type = (file.type || '').toLowerCase();
return name.endsWith('.pdf')
|| name.endsWith('.docx')
|| name.endsWith('.pptx')
|| name.endsWith('.txt')
|| name.endsWith('.md')
|| type === 'application/pdf'
|| type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|| type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|| type === 'text/plain'
|| type === 'text/markdown'
|| type === 'text/x-markdown';
},
loadDocumentFile(file) {
if (!this.isSupportedDocumentFile(file)) {
alert(window.t('chat.error.invalid_document_type'));
return;
}
const maxFiles = this.markitdownSettings.max_files_per_request || 5;
if (this.uploadDocuments.length >= maxFiles) {
alert(window.t('chat.error.too_many_documents'));
return;
}
const maxSize = (this.markitdownSettings.max_file_size_mb || 25) * 1024 * 1024;
if (file.size > maxSize) {
alert(window.t('chat.error.document_too_large'));
return;
}
const id = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
this.uploadDocuments.push({
id,
filename: file.name,
mimeType: file.type || this.mimeTypeForDocumentName(file.name),
base64: null,
_pending: true,
});
const reader = new FileReader();
reader.onload = (e) => {
const entry = this.uploadDocuments.find(doc => doc.id === id);
if (!entry?._pending) return;
entry.base64 = e.target.result;
delete entry._pending;
};
reader.onerror = () => {
const idx = this.uploadDocuments.findIndex(doc => doc.id === id);
if (idx >= 0) this.uploadDocuments.splice(idx, 1);
alert(window.t('chat.error.document_load_failed'));
};
reader.readAsDataURL(file);
},
mimeTypeForDocumentName(name) {
const lower = (name || '').toLowerCase();
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (lower.endsWith('.pptx')) return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
if (lower.endsWith('.txt')) return 'text/plain';
if (lower.endsWith('.md')) return 'text/markdown';
return 'application/octet-stream';
},
removeDocument(index) {
this.uploadDocuments.splice(index, 1);
},
handlePaste(event) {
if (!this.hasVisionSupport()) return;
const items = event.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
this.loadImageFile(file);
event.preventDefault();
break;
}
}
}
},
// ===== Content Helpers =====
getTextContent(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.filter(p => p.type === 'text').map(p => p.text).join('\n');
}
return '';
},
normalizeCopyText(text) {
if (!text) return '';
return String(text).replace(/^\n+/, '').trimEnd();
},
getCopyableMarkdown(msg) {
if (!msg) return '';
const raw = typeof msg.content === 'string'
? msg.content
: this.getTextContent(msg.content);
if (!raw) return '';
const { cleanText } = this.extractThinking(raw);
return this.normalizeCopyText(cleanText);
},
getImageUrls(content) {
if (!Array.isArray(content)) return [];
return content
.filter(p => p.type === 'image_url' && p.image_url?.url)
.map(p => p.image_url.url);
},
hasStrippedImages(content) {
if (!Array.isArray(content)) return false;
return content.some(p => p.type === 'image_url' && !p.image_url?.url);
},
getStrippedImageCount(content) {
if (!Array.isArray(content)) return [];
const count = content.filter(p => p.type === 'image_url' && !p.image_url?.url).length;
return Array.from({ length: count }, (_, i) => i);
},
getDocumentFiles(content) {
if (!Array.isArray(content)) return [];
return content
.filter(p => p.type === 'file' && p.file)
.map(p => ({
filename: p.file.filename || window.t('chat.document_not_available'),
hasData: Boolean(p.file.file_data || p.file.data),
}));
},
// ===== End Image Upload Functions =====
// Setup scroll listener for messages container
setupScrollListener() {
const container = document.getElementById('messagesContainer');
if (!container) return;
let lastScrollTop = container.scrollTop;
container.addEventListener('scroll', () => {
// Detect if user is manually scrolling
clearTimeout(this.scrollTimeout);
this.isUserScrolling = true;
// Check if at bottom (with threshold)
const threshold = 50;
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
const scrolledUp = container.scrollTop < lastScrollTop;
lastScrollTop = container.scrollTop;
if (isAtBottom) {
// User scrolled to bottom, re-enable auto-scroll
this.autoScrollEnabled = true;
} else if (scrolledUp) {
// User scrolled up - disable auto-scroll until they reach the bottom again
this.autoScrollEnabled = false;
}
// Reset user scrolling flag after scroll ends
this.scrollTimeout = setTimeout(() => {
this.isUserScrolling = false;
}, 150);
// Update active dot on scroll
clearTimeout(this._timelineScrollTimer);
this._timelineScrollTimer = setTimeout(() => {
this.computeTimelineActive();
}, 50);
});
},
async saveApiKey() {
if (!this.apiKeyInput.trim()) return;
localStorage.setItem('omlx_chat_api_key', this.apiKeyInput.trim());
this.apiKeySet = true;
this.apiKeyInput = '';
try {
await this.loadModels();
if (this.currentModel) {
await this.loadModelCapabilities(this.currentModel);
}
} catch (e) {
console.error('Failed to load models after saving API key:', e);
}
this.loadChatHistory();
},
clearApiKey() {
if (confirm(window.t('chat.confirm_change_api_key'))) {
this.stopAllStreams();
localStorage.removeItem('omlx_chat_api_key');
this.apiKeySet = false;
this.chatSessions = {};
this.streamSessions = {};
this.messages = [];
this.currentChatId = null;
}
},
getApiKey() {
return localStorage.getItem('omlx_chat_api_key') || '';
},
combineAbortSignals(signals) {
const valid = (signals || []).filter(Boolean);
if (!valid.length) return { signal: undefined, dispose() {} };
if (valid.length === 1) {
return { signal: valid[0], dispose() {} };
}
if (typeof AbortSignal.any === 'function') {
return { signal: AbortSignal.any(valid), dispose() {} };
}
const controller = new AbortController();
const listeners = [];
const dispose = () => {
for (const { signal, listener } of listeners) {
signal.removeEventListener('abort', listener);
}
listeners.length = 0;
};
const abort = () => {
dispose();
if (!controller.signal.aborted) {
controller.abort();
}
};
for (const signal of valid) {
if (signal.aborted) {
abort();
return { signal: controller.signal, dispose() {} };
}
signal.addEventListener('abort', abort);
listeners.push({ signal, listener: abort });
}
return { signal: controller.signal, dispose };
},
async loadModels() {
try {
this._adminModelsList = null;
const apiHeaders = this.getApiKey()
? { 'Authorization': `Bearer ${this.getApiKey()}` }
: {};
// Fetch models, admin model list, server health, and integration settings in parallel
const [modelsResponse, adminModelsResponse, healthResponse, settingsResponse] = await Promise.all([
fetch('/v1/models', { headers: apiHeaders }),
fetch('/admin/api/models', { credentials: 'same-origin', headers: apiHeaders }),
fetch('/health'),
fetch('/admin/api/global-settings', { credentials: 'same-origin' })
]);
if (!modelsResponse.ok) {
if (modelsResponse.status === 401) {
alert(window.t('chat.error.incorrect_api_key'));
localStorage.removeItem('omlx_chat_api_key');
this.apiKeySet = false;
this.messages = [];
this.currentChatId = null;
return;
}
throw new Error('Failed to load models');
}
// Fetch model types first (needed for filtering and VLM detection)
await this.fetchModelTypes();
// Build alias → gateway id mapping from admin API
// (admin/api/models returns directory names as model.id, with alias in settings)
this.aliasToGateway = {};
if (adminModelsResponse.ok) {
const adminData = await adminModelsResponse.json();
this._adminModelsList = adminData.models || [];
for (const m of this._adminModelsList) {
const alias = m.settings?.model_alias || m.model_alias;
if (alias) {
this.aliasToGateway[alias] = m.id;
}
}
}
if (settingsResponse.ok) {
const settingsData = await settingsResponse.json();
const integrations = settingsData.integrations || {};
this.markitdownSettings = {
enabled: integrations.markitdown_enabled !== false,
max_file_size_mb: integrations.markitdown_max_file_size_mb || 25,
max_files_per_request: integrations.markitdown_max_files_per_request || 5,
};
}
this._mirrorAliasModelTypes();
const modelsData = await modelsResponse.json();
const allModels = modelsData.data || [];
// Favorite gateway ids from the admin list; used to sort favorites first
const favoriteIds = new Set(
(this._adminModelsList || []).filter(m => m.is_favorite).map(m => m.id)
);
// Build available models with gateway id (directory name) as the value
// Store alias for display (in case we want to show it later)
this.availableModels = this.dedupeAvailableModels(
allModels.filter(m => {
const gatewayId = this.aliasToGateway[m.id] || m.id;
const t = (this.modelTypeMap[gatewayId] || '').toLowerCase();
return !t.startsWith('audio_') && t !== 'embedding' && t !== 'reranker';
}).map(m => ({
id: this.aliasToGateway[m.id] || m.id, // gateway id for API calls
name: m.id, // original name (alias or directory name) for display
owned_by: m.owned_by,
is_favorite: favoriteIds.has(this.aliasToGateway[m.id] || m.id)
}))
);
// Get default model from health endpoint
let defaultModel = null;
if (healthResponse.ok) {
const healthData = await healthResponse.json();
defaultModel = healthData.default_model;
}
this.reconcileCurrentModelAfterLoad(defaultModel);
} catch (error) {
console.error('Error loading models:', error);
}
},
async fetchAdminModelsList({ force = false } = {}) {
if (!force && this._adminModelsList) return this._adminModelsList;
if (this._adminModelsListPromise) return this._adminModelsListPromise;
this._adminModelsListPromise = fetch('/admin/api/models', { credentials: 'same-origin' })
.then(async (response) => {
if (!response.ok) throw new Error('Failed to load admin models');
const data = await response.json();
this._adminModelsList = data.models || [];
return this._adminModelsList;
})
.finally(() => {
this._adminModelsListPromise = null;
});
try {
return await this._adminModelsListPromise;
} catch (e) {
this._adminModelsList = null;
throw e;
}
},
async loadModelCapabilities(modelId) {
if (!modelId) return;
try {
const models = await this.fetchAdminModelsList();
const model = models.find(m => m.id === modelId);
if (!model) return;
this.isMtpCompatible = !!model.mtp_compatible;
this.isVlm = model.model_type === 'vlm';
} catch (e) {
console.error('Failed to load model capabilities:', e);
}
},
/** One-time defaults from admin when a chat session has no saved settings yet. */
async loadAdminModelDefaults(modelId) {
if (!modelId) return;
try {
const models = await this.fetchAdminModelsList();
const model = models.find(m => m.id === modelId);
if (!model) return;
this.isMtpCompatible = !!model.mtp_compatible;
this.isVlm = model.model_type === 'vlm';
const s = model.settings || {};
this.modelSettings = {
...this.defaultModelSettings(),
temperature: s.temperature ?? null,
max_tokens: s.max_tokens ?? null,
top_p: s.top_p ?? null,
top_k: s.top_k ?? null,
min_p: s.min_p ?? null,
repetition_penalty: s.repetition_penalty ?? null,
presence_penalty: s.presence_penalty ?? null,
enable_thinking: s.enable_thinking ?? null,
thinking_budget_enabled: !!(s.thinking_budget_enabled || (s.thinking_budget_tokens != null && s.thinking_budget_tokens > 0)),
thinking_budget_tokens: (s.thinking_budget_enabled || (s.thinking_budget_tokens != null && s.thinking_budget_tokens > 0))
? this.normalizeThinkingBudgetTokens(s.thinking_budget_tokens)
: null,
};
this.speculativeEngine = 'none';
this.speculativeDraftModel = '';
this.modelSettingsDirty = false;
} catch (e) {
console.error('Failed to load admin model defaults:', e);
}
},
async ensureSessionModelSettings(session, modelId) {
if (!session || !modelId) return;
const gatewayId = this.resolveGatewayModelId(modelId);
if (this.loadModelSettingsForModel(session, gatewayId)) {
await this.loadModelCapabilities(gatewayId);
return;
}
const legacyFlat = session.model === gatewayId && session.modelSettings;
if (legacyFlat) {
this.applySessionModelSettings(session);
await this.loadModelCapabilities(gatewayId);
return;
}
session.modelSettings = null;
session.speculativeEngine = null;
session.speculativeDraftModel = null;
await this.loadAdminModelDefaults(gatewayId);
this.syncSessionModelSettingsFromUi(session);
},
onModelSettingsChange() {
this.modelSettingsDirty = true;
this.schedulePersistModelSettings();
},
cancelPersistModelSettingsTimer() {
clearTimeout(this._persistModelSettingsTimer);
this._persistModelSettingsTimer = null;
},
schedulePersistModelSettings() {
this.cancelPersistModelSettingsTimer();
const chatId = this.currentChatId;
this._persistModelSettingsTimer = setTimeout(() => {
this._persistModelSettingsTimer = null;
if (!chatId) return;
const session = this.getChatSession(chatId, false);
if (!session) return;
if (chatId === this.currentChatId) {
this.syncSessionModelSettingsFromUi(session);
}
this.persistChatSettingsToHistory(chatId);
if (chatId === this.currentChatId) {
this.modelSettingsDirty = false;
}
}, 400);
},
// Poll stats during generation (per-stream request_id claims avoid bleed across chats/models)
async fetchStats() {
try {
const resp = await fetch('/admin/api/stats', { credentials: 'same-origin' });
if (!resp.ok) return;
const data = await resp.json();
if (this.anyChatStreaming()) {
this.claimStatsRequests(data);
}
for (const [chatId, stream] of Object.entries(this.streamSessions)) {
if (!stream?.isStreaming) continue;
stream._liveFooterStats = this.computeLiveFooterStats(chatId, stream, data);
}
const stream = this.getStreamSession(this.currentChatId, false);
if (!stream?.isStreaming) return;
this.recentStats = stream._liveFooterStats
? { ...stream._liveFooterStats }
: this.computeLiveFooterStats(this.currentChatId, stream, data);
} catch (e) {
console.error('Failed to fetch stats:', e);
}
},
startStatsPolling(chatId = this.currentChatId) {
if (!this.statsInterval) {
this.statsInterval = setInterval(() => this.fetchStats(), 200);
}
this.fetchStats();
if (chatId === this.currentChatId) {
this.recentStats = {
avg_prefill_tps: 0,
avg_generation_tps: 0,
thinking_time: 0,
total_time: 0,
prompt_tokens: 0,
thinking_tokens: 0,
total_tokens: 0,
};
}
},
stopStatsPolling() {
if (this.statsInterval) {
clearInterval(this.statsInterval);
this.statsInterval = null;
}
},
// ===== PROFILE Section (Prompt Profiles) =====
loadPromptProfiles() {
try {
const raw = localStorage.getItem('omlx_chat_prompt_profiles');
if (raw) {
this.promptProfiles = JSON.parse(raw);
} else {
// Migrate from old single-default format
const oldDefault = localStorage.getItem('omlx_chat_system_prompt');
if (oldDefault) {
this.promptProfiles = [{ name: 'System Default', content: oldDefault }];
this._persistPromptProfiles();
localStorage.removeItem('omlx_chat_system_prompt');
}
}
// Ensure System Default profile always exists
if (!this.promptProfiles.some(p => p.name === 'System Default')) {
this.promptProfiles.unshift({ name: 'System Default', content: '' });
this._persistPromptProfiles();
}
// Apply System Default profile's prompt if none set
if (!this.systemPrompt) {
const sysDefault = this.promptProfiles.find(p => p.name === 'System Default');
if (sysDefault) {
this.systemPrompt = sysDefault.content;
this.activePromptProfile = 'System Default';
}
}
} catch (e) {
this.promptProfiles = [];
}
},
_persistPromptProfiles() {
localStorage.setItem('omlx_chat_prompt_profiles', JSON.stringify(this.promptProfiles));
},
selectPromptProfile(name) {
if (!name) {
// Custom — keep current prompt, just clear association
this.activePromptProfile = null;
const session = this.getChatSession(this.currentChatId, true);
if (session) session.activeProfile = null;
return;
}
const profile = this.promptProfiles.find(p => p.name === name);
if (profile) {
this.systemPrompt = profile.content;
this.activePromptProfile = name;
this.promptDirty = false;
// Sync to current session
const session = this.getChatSession(this.currentChatId, true);
if (session) {
session.systemPrompt = profile.content;
session.activeProfile = this.activePromptProfile;
}
}
},
savePromptProfile() {
if (!this.activePromptProfile) return;
const profile = this.promptProfiles.find(p => p.name === this.activePromptProfile);
if (profile) {
profile.content = this.systemPrompt;
this._persistPromptProfiles();
this.promptDirty = false;
// Sync to session
const session = this.getChatSession(this.currentChatId, true);
if (session) {
session.systemPrompt = this.systemPrompt;
session.activeProfile = this.activePromptProfile;
}
}
},
deletePromptProfile(name) {
if (name === 'System Default') return;
this.promptProfiles = this.promptProfiles.filter(p => p.name !== name);
if (this.activePromptProfile === name) {
this.activePromptProfile = null;
}
this._persistPromptProfiles();
this.profileDeleteConfirm = null;
},
startRenamingPromptProfile() {
if (!this.activePromptProfile) return;
this.newProfileNameDraft = this.activePromptProfile;
this.editingPromptProfileName = true;
},
commitRenamingPromptProfile() {
const trimmed = this.newProfileNameDraft.trim();
if (!trimmed || !this.activePromptProfile) {
this.cancelRenamingPromptProfile();
return;
}
// Check for duplicate name (excluding current)
if (this.promptProfiles.some(p => p.name === trimmed && p.name !== this.activePromptProfile)) {
this.cancelRenamingPromptProfile();
return;
}
const profile = this.promptProfiles.find(p => p.name === this.activePromptProfile);
if (profile) {
profile.name = trimmed;
this._persistPromptProfiles();
this.activePromptProfile = trimmed;
}
this.editingPromptProfileName = false;
this.newProfileNameDraft = '';
},
cancelRenamingPromptProfile() {
this.editingPromptProfileName = false;
this.newProfileNameDraft = '';
},
// ===== PROFILE Tab: Add New Profile =====
addNewPromptProfile() {
let baseName = 'New Profile';
let counter = 1;
let name = baseName;
while (this.promptProfiles.some(p => p.name === name)) {
name = `${baseName} (${counter})`;
counter++;
}
this.promptProfiles.push({ name, content: '' });
this._persistPromptProfiles();
this.activePromptProfile = name;
this.systemPrompt = '';
this.promptDirty = false;
this.newProfileNameDraft = name;
this.editingPromptProfileName = true;
this.$nextTick(() => {
const input = document.querySelector('[x-model="newProfileNameDraft"]');
if (input) input.focus();
});
const session = this.getChatSession(this.currentChatId, true);
if (session) session.systemPrompt = '';
},
async selectModel(modelId) {
const session = this.getChatSession(this.currentChatId, true);
const gatewayId = this.resolveGatewayModelId(modelId);
try {
if (session?.model && session.model !== gatewayId) {
this.syncSessionModelSettingsFromUi(session);
this.saveModelSettingsForModel(session, session.model);
}
this.currentModel = gatewayId;
if (session) {
session.model = gatewayId;
if (!this.loadModelSettingsForModel(session, gatewayId)) {
session.modelSettings = null;
session.speculativeEngine = null;
session.speculativeDraftModel = null;
await this.ensureSessionModelSettings(session, gatewayId);
this.saveModelSettingsForModel(session, gatewayId);
} else {
await this.loadModelCapabilities(gatewayId);
}
this.persistChatSettingsToHistory(this.currentChatId);
} else {
await this.loadModelCapabilities(gatewayId);
}
} catch (e) {
console.error('Failed to switch model:', e);
}
},
loadChatHistory() {
const history = localStorage.getItem('omlx_chat_history');
if (history) {
try {
this.chatHistory = JSON.parse(history);
this.chatHistory.sort((a, b) => {
const ta = a.updatedAt ? Date.parse(a.updatedAt) : 0;
const tb = b.updatedAt ? Date.parse(b.updatedAt) : 0;
return (Number.isNaN(tb) ? 0 : tb) - (Number.isNaN(ta) ? 0 : ta);
});
let migrated = false;
for (const chat of this.chatHistory) {
if (chat.title && /^chat\.untitled/.test(String(chat.title))) {
chat.title = this.untitledChatTitle();
migrated = true;
}
}
if (migrated) {
this.saveChatHistory();
}
} catch (e) {
this.chatHistory = [];
}
}
},
saveChatHistory() {
const key = 'omlx_chat_history';
const persist = () => localStorage.setItem(key, JSON.stringify(this.chatHistory));
try {
persist();
} catch (e) {
if (e?.name !== 'QuotaExceededError' && !/quota/i.test(String(e?.message || ''))) {
console.error('Failed to save chat history:', e);
return;
}
while (this.chatHistory.length > 1) {
this.chatHistory.pop();
try {
persist();
console.warn('Chat history trimmed to fit localStorage quota');
return;
} catch (retryErr) {
if (retryErr?.name !== 'QuotaExceededError' && !/quota/i.test(String(retryErr?.message || ''))) {
console.error('Failed to save trimmed chat history:', retryErr);
return;
}
}
}
console.error('Chat history too large for localStorage even after trimming');
}
},
async startNewChat() {
this.cancelPersistModelSettingsTimer();
const prevChatId = this.currentChatId;
if (prevChatId) {
const prevSession = this.getChatSession(prevChatId, false);
if (prevSession) {
prevSession.messages = this.messages;
this.saveCurrentChat(prevChatId);
}
}
const chatId = 'chat_' + Date.now();
const sysDefault = this.promptProfiles.find(p => p.name === 'System Default');
const systemPrompt = sysDefault ? sysDefault.content : '';
this.currentChatId = chatId;
this.uploadImages = [];
this.uploadDocuments = [];
this.inputMessage = '';
this.systemPrompt = systemPrompt;
this.activePromptProfile = sysDefault ? sysDefault.name : null;
this.editingIndex = null;
this.editContent = '';
this.editImages = [];
this.renamingChatId = null;
this.isMobile = window.innerWidth < 768;
this.sidebarOpen = window.innerWidth >= 768;
const session = {
messages: [],
model: this.currentModel,
systemPrompt,
activeProfile: this.activePromptProfile,
modelSettings: null,
speculativeEngine: null,
speculativeDraftModel: null,
modelSettingsByModel: {},
};
this.chatSessions[chatId] = session;
this.messages = session.messages;
await this.ensureSessionModelSettings(session, this.currentModel);
if (this.currentModel) {
this.saveModelSettingsForModel(session, this.currentModel);
}
this.resetStreamSession(this.getStreamSession(chatId, true));
this.recentStats = this.emptyStats();
this.stopStatsPollingIfIdle();
this.saveCurrentChat(chatId);
this.$nextTick(() => {
const input = this.$refs.messageInput;
if (input) {
this.resetTextareaHeight(input);
input.focus();
}
if (window.innerWidth < 768) {
this.sidebarOpen = false;
}
const container = document.getElementById('messagesContainer');
if (container) {
container.scrollTop = 0;
}
this.computeTimelineDots();
this.computeTimelineActive();
});
},
async loadChat(chatId) {
this.cancelPersistModelSettingsTimer();
if (this.currentChatId && this.currentChatId !== chatId) {
const prevSession = this.getChatSession(this.currentChatId, false);
if (prevSession) {
prevSession.messages = this.messages;
this.saveCurrentChat(this.currentChatId);
}
}
const session = this.getChatSession(chatId, true);
if (session) {
this.ensureMessageIds(session.messages);
session.messages.forEach(m => this.ensureVariantMeta(m, session));
this.currentChatId = chatId;
this.messages = session.messages;
this.systemPrompt = session.systemPrompt || '';
this.activePromptProfile = session.activeProfile
&& this.promptProfiles.some(p => p.name === session.activeProfile)
? session.activeProfile : null;
if (session.model) {
// Resolve legacy alias → gateway id for backward compatibility
const gatewayId = this.aliasToGateway[session.model] || session.model;
this.currentModel = gatewayId;
session.model = gatewayId;
if (!session.modelSettingsByModel) session.modelSettingsByModel = {};
if (session.modelSettings && !session.modelSettingsByModel[gatewayId]) {
session.modelSettingsByModel[gatewayId] = {
modelSettings: this.cloneData(session.modelSettings),
speculativeEngine: session.speculativeEngine,
speculativeDraftModel: session.speculativeDraftModel,
};
}
if (!this.loadModelSettingsForModel(session, gatewayId)) {
await this.ensureSessionModelSettings(session, gatewayId);
this.saveModelSettingsForModel(session, gatewayId);
} else {
await this.loadModelCapabilities(gatewayId);
}
}
if (session.messages && session.messages.length > 0) {
this.syncFooterStatsFromMessages(session.messages);
} else {
this.recentStats = this.emptyStats();
}
this.scheduleEnhanceMessages();
this.ensureStatsPollingForCurrentChat();
}
this.isMobile = window.innerWidth < 768;
this.sidebarOpen = window.innerWidth >= 768;
},
saveCurrentChat(chatId = this.currentChatId, messages = null, model = null, systemPrompt = null) {
const session = this.getChatSession(chatId, false);
const messagesToSave = messages ?? session?.messages ?? (chatId === this.currentChatId ? this.messages : []);
if (!chatId) return;
const existingIndex = this.chatHistory.findIndex(c => c.id === chatId);
const existingChat = existingIndex >= 0 ? this.chatHistory[existingIndex] : null;
// Strip base64 image data from content arrays to avoid localStorage quota issues
const messagesForStorage = messagesToSave.map(msg => {
if (!Array.isArray(msg.content)) return msg;
return {
...msg,
content: msg.content.map(part => {
if (part.type === 'image_url') {
return { type: 'image_url', image_url: { url: '' } };
}
if (part.type === 'file') {
return {
type: 'file',
file: {
filename: part.file?.filename || '',
mime_type: part.file?.mime_type || '',
data: '',
},
};
}
return part;
})
};
});
const title = this.resolveAutoChatTitle(messagesToSave, existingChat);
const chatModel = model ?? session?.model ?? (chatId === this.currentChatId ? this.currentModel : null);
const chatSystemPrompt = systemPrompt ?? session?.systemPrompt ?? (chatId === this.currentChatId ? this.systemPrompt : '');
if (session && chatId === this.currentChatId) {
this.syncSessionModelSettingsFromUi(session);
}
const settingsSnap = session
? {
modelSettings: session.modelSettings,
speculativeEngine: session.speculativeEngine,
speculativeDraftModel: session.speculativeDraftModel,
}
: this.captureSessionModelSettings();
const chatData = {
id: chatId,
title,
manualTitle: existingChat?.manualTitle || false,
messages: messagesForStorage,
model: chatModel,
systemPrompt: chatSystemPrompt,
activeProfile: session?.activeProfile ?? this.activePromptProfile ?? null,
modelSettings: settingsSnap.modelSettings,
speculativeEngine: settingsSnap.speculativeEngine,
speculativeDraftModel: settingsSnap.speculativeDraftModel,
modelSettingsByModel: session
? this.cloneData(session.modelSettingsByModel || {})
: {},
updatedAt: new Date().toISOString()
};
if (session) {
session.messages = messagesToSave;
session.model = chatModel;
session.systemPrompt = chatSystemPrompt;
session.modelSettings = settingsSnap.modelSettings;
session.speculativeEngine = settingsSnap.speculativeEngine;
session.speculativeDraftModel = settingsSnap.speculativeDraftModel;
if (chatModel) {
this.saveModelSettingsForModel(session, chatModel);
}
}
if (existingIndex >= 0) {
this.chatHistory[existingIndex] = chatData;
} else {
this.chatHistory.unshift(chatData);
}
// Keep only last 50 chats
this.chatHistory = this.chatHistory.slice(0, 50);
this.saveChatHistory();
},
startRenamingChat(chat) {
this.renamingChatId = chat.id;
this.renameDraft = chat.title || this.untitledChatTitle();
this.$nextTick(() => {
const ref = this.$refs.renameInput;
const input = Array.isArray(ref) ? ref[0] : ref;
if (!input) return;
input.focus();
input.select();
});
},
cancelRenamingChat() {
this.renamingChatId = null;
this.renameDraft = '';
},
saveRenamedChat(chatId) {
const chat = this.chatHistory.find(c => c.id === chatId);
if (!chat) return;
chat.title = this.renameDraft.trim() || this.untitledChatTitle();
chat.manualTitle = true;
chat.updatedAt = new Date().toISOString();
this.saveChatHistory();
this.cancelRenamingChat();
},
async sendMessage() {
const userText = this.inputMessage.trim();
const images = this.uploadImages.filter(img => img.base64);
const documents = this.uploadDocuments.filter(doc => doc.base64);
if (this.hasPendingUploads()) return;
if (!userText && images.length === 0 && documents.length === 0) return;
if (!this.currentModel || this.isCurrentChatStreaming()) return;
// Clear input state
this.inputMessage = '';
this.uploadImages = [];
this.uploadDocuments = [];
this.$nextTick(() => {
if (this.$refs.messageInput) {
this.resetTextareaHeight(this.$refs.messageInput);
}
});
if (!this.currentChatId) {
await this.startNewChat();
}
const chatId = this.currentChatId;
const sourceModel = this.currentModel;
const sourceSystemPrompt = this.systemPrompt;
const chatSession = this.getChatSession(chatId, true);
chatSession.model = sourceModel;
chatSession.systemPrompt = sourceSystemPrompt;
// Build message content (OpenAI content array for images, string for text-only)
let content;
if (images.length > 0 || documents.length > 0) {
content = [];
for (const img of images) {
content.push({ type: 'image_url', image_url: { url: img.base64 } });
}
for (const doc of documents) {
content.push({
type: 'file',
file: {
filename: doc.filename,
file_data: doc.base64,
},
});
}
if (userText) {
content.push({ type: 'text', text: userText });
}
} else {
content = userText;
}
const userMsg = { id: this.newMessageId(), role: 'user', content };
chatSession.messages.push(userMsg);
this.messages = chatSession.messages;
this.forceScrollToBottom();
await this.streamResponse({
chatId,
model: sourceModel,
systemPrompt: sourceSystemPrompt,
_profile: this.resolveStreamProfile(null, chatSession),
}, 0);
},
async streamResponse(streamContext = null, depth = 0) {
const context = {
chatId: streamContext?.chatId || this.currentChatId,
model: streamContext?.model || this.currentModel,
systemPrompt: streamContext?.systemPrompt ?? this.systemPrompt,
_profile: null,
_variantUserIndex: streamContext?._variantUserIndex ?? null,
_excludeVariantsAtUserIndex: streamContext?._excludeVariantsAtUserIndex ?? null,
_apiVariantChainId: streamContext?._apiVariantChainId ?? null,
};
const chatSession = this.getChatSession(context.chatId, true);
const stream = this.getStreamSession(context.chatId, true);
context._profile = this.resolveStreamProfile(streamContext, chatSession);
context.model = context.model || chatSession?.model || this.currentModel;
context.systemPrompt = context.systemPrompt ?? chatSession?.systemPrompt ?? '';
if (context.chatId === this.currentChatId && context.systemPrompt !== this.systemPrompt) {
this.systemPrompt = context.systemPrompt;
}
chatSession.model = context.model;
chatSession.systemPrompt = context.systemPrompt;
if (depth > this.MAX_TOOL_DEPTH) {
chatSession.messages.push({
id: this.newMessageId(),
role: 'assistant',
content: `Error: Maximum tool call depth (${this.MAX_TOOL_DEPTH}) exceeded. The model may be stuck in a loop.`,
model: context.model,
_profile: context._profile,
_perfVisible: false,
});
this.saveCurrentChat(context.chatId, chatSession.messages, context.model, context.systemPrompt);
this.resetStreamSession(stream);
return;
}
// Initialise timing and status log once, at the top of a new prompt
if (depth === 0) {
this.resetStreamSession(stream);
stream._statusStart = Date.now();
stream.targetMessageId = this.newMessageId();
stream.sourceModel = context.model;
stream.sourceSystemPrompt = context.systemPrompt;
stream.sourceProfile = context._profile;
if (context._variantUserIndex == null) {
for (let i = chatSession.messages.length - 1; i >= 0; i--) {
if (chatSession.messages[i].role === 'user') {
context._variantUserIndex = i;
break;
}
}
}
this.startStatsPolling(context.chatId);
}
stream.isStreaming = true;
stream.sourceModel = context.model;
stream.sourceSystemPrompt = context.systemPrompt;
stream.sourceProfile = context._profile;
stream.streamingContent = '';
stream.streamingThinking = '';
stream.streamingThinkingOpen = false;
stream.abortController = new AbortController();
this.autoScrollEnabled = true;
if (stream.targetMessageId) {
context._apiVariantChainId = stream.targetMessageId;
}
const requestBody = this.buildChatCompletionBody(chatSession.messages, context, depth);
this.setEngineStatus(context.chatId, { text: 'Starting' });
this.startPrefillPolling(context.chatId);
try {
const response = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.getApiKey()}`
},
body: JSON.stringify(requestBody),
signal: stream.abortController.signal
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const toolCallsMap = {}; // accumulate streaming tool_call chunks by index
let finishReason = null;
let firstToken = false; // flipped on first content/reasoning delta
let lastUsage = null; // filled by the stream_options usage chunk
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() || '';
for (const line of lines) {
if (line.trim() === '' || line.trim() === 'data: [DONE]') continue;
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
const choice = data.choices?.[0];
const delta = choice?.delta;
if (choice?.finish_reason) finishReason = choice.finish_reason;
// Usage chunk arrives as { choices: [], usage: {...} }
if (data.usage && !delta) {
lastUsage = data.usage;
stream._lastUsage = data.usage;
continue;
}
// Accumulate streaming tool_call chunks
if (delta?.tool_calls) {
if (!firstToken) {
firstToken = true;
this.stopPrefillPolling(context.chatId);
}
for (const tc of delta.tool_calls) {
const i = tc.index ?? 0;
if (!toolCallsMap[i]) toolCallsMap[i] = { id: '', type: 'function', function: { name: '', arguments: '' } };
if (tc.id) toolCallsMap[i].id = tc.id;
if (tc.function?.name) toolCallsMap[i].function.name += tc.function.name;
if (tc.function?.arguments) toolCallsMap[i].function.arguments += tc.function.arguments;
}
// Clear any thinking content so it doesn't show as response text
// Also reset thinking state so the close tag isn't appended later
if (stream.streamingContent) {
stream.streamingContent = '';
stream.thinkingState.isInThinking = false;
stream.thinkingState.thinkingStartTime = null;
}
// Update live status
const names = Object.values(toolCallsMap).map(t => t.function.name).filter(Boolean);
if (names.length > 0) {
const unique = [...new Set(names)];
const liveLabel = unique.length > 3
? `Calling ${unique.slice(0, 2).join(', ')} +${unique.length - 2} more`
: `Calling ${unique.join(', ')}`;
stream.engineStatus = { text: liveLabel, icon: 'wrench' };
}
}
if (delta?.reasoning_content) {
if (!firstToken) {
firstToken = true;
stream._ttft = (Date.now() - stream._statusStart) / 1000;
this.stopPrefillPolling(context.chatId);
}
if (!stream.thinkingState.isInThinking) {
stream.thinkingState.isInThinking = true;
stream.thinkingState.thinkingStartTime = Date.now();
this.setEngineStatus(context.chatId, { text: 'Thinking' });
}
// Accumulate into live thinking bubble
stream.streamingThinking += delta.reasoning_content;
}
if (delta?.content) {
if (!firstToken) {
firstToken = true;
stream._ttft = (Date.now() - stream._statusStart) / 1000;
this.stopPrefillPolling(context.chatId);
}
// Log thinking duration to timeline when content starts
if (stream.thinkingState.isInThinking) {
const thinkSecs = ((Date.now() - stream.thinkingState.thinkingStartTime) / 1000).toFixed(1);
this.setEngineStatus(context.chatId, { text: `Thought for ${thinkSecs}s` });
stream.thinkingState.isInThinking = false;
stream.thinkingState.thinkingStartTime = null;
stream._thinkSecs = thinkSecs;
}
// Only set finalContent on non-whitespace content
if (!stream.finalContent && delta.content.trim()) {
stream.finalContent = true;
stream.engineStatus = null;
}
stream.streamingContent += delta.content;
if (this.currentChatId === context.chatId) {
this.scrollToBottom();
}
}
} catch (e) {
console.error('Error parsing SSE:', e);
}
}
}
}
// If model ended while still thinking (tool-call turn or manual stop), log elapsed and reset
if (stream.thinkingState.isInThinking) {
const thinkSecs = ((Date.now() - stream.thinkingState.thinkingStartTime) / 1000).toFixed(1);
this.setEngineStatus(context.chatId, { text: `Thought for ${thinkSecs}s` });
stream.thinkingState.isInThinking = false;
stream.thinkingState.thinkingStartTime = null;
stream._thinkSecs = thinkSecs;
stream._thinkTokens = this.recentStats?.thinking_tokens || 0;
}
// Log usage metrics as a single compact entry
if (lastUsage) {
const { prompt_tokens: pt, completion_tokens: ct,
prompt_tokens_per_second: ptps, generation_tokens_per_second: gtps,
time_to_first_token: ttft } = lastUsage;
const parts = [];
if (pt != null) parts.push(`${pt}t prompt`);
if (ct != null) parts.push(`${ct}t generated`);
if (ptps != null) parts.push(`prefill ${Math.round(ptps)} tok/s`);
if (gtps != null) parts.push(`gen ${Math.round(gtps)} tok/s`);
if (ttft != null) parts.push(`ttft ${ttft}s`);
if (parts.length) this.setEngineStatus(context.chatId, { text: parts.join(' · ') });
// Sync exact final timings to the performance panel
this.recentStats = {
...this.recentStats,
avg_prefill_tps: ptps != null ? ptps : (this.recentStats?.avg_prefill_tps || 0),
avg_generation_tps: gtps != null ? gtps : (this.recentStats?.avg_generation_tps || 0),
prompt_tokens: pt != null ? pt : (this.recentStats?.prompt_tokens || 0),
total_tokens: ct != null ? ct : (this.recentStats?.total_tokens || 0)
};
}
const toolCalls = Object.values(toolCallsMap);
if (toolCalls.length > 0) {
// Log the resolved tool set once — summarise when count is large
const names = toolCalls.map(t => t.function.name).filter(Boolean);
const uniqueNames = [...new Set(names)];
const label = toolCalls.length > 3
? `Calling ${uniqueNames.slice(0, 2).join(', ')} +${toolCalls.length - 2} more`
: `Calling ${names.join(', ')}`;
if (names.length) this.setEngineStatus(context.chatId, { text: label, icon: 'wrench' });
// Store the assistant tool_calls turn (hidden from chat UI)
chatSession.messages.push({
id: this.newMessageId(),
role: 'assistant',
content: stream.streamingContent || null,
reasoning_content: stream.streamingThinking || null,
model: context.model,
tool_calls: toolCalls,
_profile: context._profile,
_ui: false,
});
// Record per-tool start times for elapsed tracking
toolCalls.forEach((tc) => { stream._toolTimers[tc.function.name] = Date.now(); });
// Execute all tools in parallel
const results = await Promise.all(toolCalls.map(async (tc, i) => {
const toolName = tc.function.name;
let args = {};
try { args = JSON.parse(tc.function.arguments || '{}'); } catch (e) { }
try {
const timeoutSignal = AbortSignal.timeout?.(this.TOOL_TIMEOUT_MS);
const { signal: combinedSignal, dispose: disposeCombinedSignal } = this.combineAbortSignals([
stream.abortController?.signal,
timeoutSignal,
]);
let execResp;
try {
execResp = await fetch('/v1/mcp/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.getApiKey()}` },
body: JSON.stringify({ tool_name: toolName, arguments: args }),
signal: combinedSignal,
});
} finally {
disposeCombinedSignal();
}
const elapsed = ((Date.now() - (stream._toolTimers[toolName] || Date.now())) / 1000).toFixed(2);
if (!execResp.ok) {
return { content: `Error: HTTP ${execResp.status}`, error: true, toolName, elapsed };
}
const execData = await execResp.json();
const content = typeof execData.content === 'string'
? execData.content
: JSON.stringify(execData.content ?? execData);
return { content, error: false, toolName, elapsed };
} catch (e) {
const elapsed = ((Date.now() - (stream._toolTimers[toolName] || Date.now())) / 1000).toFixed(2);
const isTimeout = e.name === 'TimeoutError';
const msg = isTimeout ? `Error: Tool timed out after ${this.TOOL_TIMEOUT_MS / 1000}s` : `Error: ${e.message}`;
return { content: msg, error: true, toolName, elapsed };
}
}));
// Log each tool outcome to the status timeline
results.forEach((result) => {
if (result.error) {
this.setEngineStatus(context.chatId, { text: `${result.toolName} failed`, icon: 'error' });
} else {
this.setEngineStatus(context.chatId, { text: `${result.toolName} done in ${result.elapsed}s`, icon: 'check' });
}
});
// Push hidden tool result messages
toolCalls.forEach((tc, i) => {
chatSession.messages.push({
id: this.newMessageId(),
role: 'tool',
tool_call_id: tc.id,
content: results[i].content,
_ui: false
});
});
this.saveCurrentChat(context.chatId, chatSession.messages, context.model, context.systemPrompt);
if (stream.abortController?.signal.aborted) {
return;
}
stream.streamingContent = '';
stream.streamingThinking = '';
stream.streamingThinkingOpen = false;
this.resetStreamRenderState(stream);
// Recurse — model synthesises final answer using tool results
await this.streamResponse(context, depth + 1);
return;
}
// --- end MCP tool call loop ---
// Always push the completed assistant message — even if content is empty.
// This prevents the streaming box from disappearing with no replacement
// when the model emits only whitespace, only reasoning tokens, or nothing at all.
const assistantId = stream.targetMessageId || this.newMessageId();
const assistantMsg = {
id: assistantId,
role: 'assistant',
content: stream.streamingContent || '',
reasoning_content: stream.streamingThinking || null,
model: context.model,
meta: this.captureGenerationContext(context.model, context.systemPrompt, context._profile),
_perfVisible: false,
_thinkingOpen: false,
};
this.attachThinkingToAssistantMessage(assistantMsg, stream.streamingThinking);
this.attachProfileToAssistantMessage(assistantMsg, context._profile);
chatSession.messages.push(assistantMsg);
this._setActiveVariant(chatSession, assistantId, context._variantUserIndex);
this.saveCurrentChat(context.chatId, chatSession.messages, context.model, context.systemPrompt);
if (this.currentChatId === context.chatId) {
this.scheduleEnhanceMessages();
}
} catch (error) {
console.log('[streamResponse] catch error:', error.name, error.message);
if (error.name === 'AbortError') {
console.log('[streamResponse] AbortError caught - stream stopped by user');
// Reset thinking state (reasoning now lives in streamingThinking, not streamingContent)
if (stream.thinkingState.isInThinking) {
stream.thinkingState.isInThinking = false;
stream.thinkingState.thinkingStartTime = null;
}
// Stream was stopped by user — push whatever was accumulated
if (stream.streamingContent || stream.streamingThinking) {
const assistantId = stream.targetMessageId || this.newMessageId();
const assistantMsg = {
id: assistantId,
role: 'assistant',
content: stream.streamingContent,
reasoning_content: stream.streamingThinking || null,
model: context.model,
meta: this.captureGenerationContext(context.model, context.systemPrompt, context._profile),
_perfVisible: false,
_thinkingOpen: false,
};
this.attachThinkingToAssistantMessage(assistantMsg, stream.streamingThinking);
this.attachProfileToAssistantMessage(assistantMsg, context._profile);
chatSession.messages.push(assistantMsg);
this._setActiveVariant(chatSession, assistantId, context._variantUserIndex);
this.saveCurrentChat(context.chatId, chatSession.messages, context.model, context.systemPrompt);
if (this.currentChatId === context.chatId) {
this.scheduleEnhanceMessages();
}
}
} else {
console.error('Streaming error:', error);
const assistantId = stream.targetMessageId || this.newMessageId();
const errorMsg = {
id: assistantId,
role: 'assistant',
content: `Error: ${error.message}`,
model: context.model,
meta: this.captureGenerationContext(context.model, context.systemPrompt, context._profile),
_perfVisible: false,
};
this.attachProfileToAssistantMessage(errorMsg, context._profile);
chatSession.messages.push(errorMsg);
this._setActiveVariant(chatSession, assistantId, context._variantUserIndex);
this.saveCurrentChat(context.chatId, chatSession.messages, context.model, context.systemPrompt);
if (this.currentChatId === context.chatId) {
this.scheduleEnhanceMessages();
}
}
} finally {
// Attach performance timeline and thinking to the final assistant message, once per prompt
if (depth === 0) {
let msg = stream.targetMessageId
? chatSession.messages.find(m => m.id === stream.targetMessageId)
: null;
if (!msg) {
for (let i = chatSession.messages.length - 1; i >= 0; i--) {
const candidate = chatSession.messages[i];
if (candidate.role === 'assistant' && candidate._ui !== false) {
msg = candidate;
break;
}
}
}
if (msg) {
const totalTime = ((Date.now() - stream._statusStart) / 1000).toFixed(2);
const perfEntries = [];
const meta = msg.meta || {};
const gen = meta.generation || {};
const parts = [];
const scalarLabels = [
['temperature', 'temp'],
['max_tokens', 'max_tok'],
['top_p', 'top_p'],
['top_k', 'top_k'],
['min_p', 'min_p'],
['repetition_penalty', 'rep_pen'],
['presence_penalty', 'pres_pen'],
];
for (const [key, label] of scalarLabels) {
if (gen[key] != null) parts.push(`${label}=${gen[key]}`);
}
const ctk = gen.chat_template_kwargs;
if (ctk && typeof ctk.enable_thinking === 'boolean') {
parts.push(`think=${ctk.enable_thinking ? 'on' : 'off'}`);
}
if (gen.thinking_budget != null) parts.push(`think_budget=${gen.thinking_budget}`);
const modelLabel = meta.modelDisplay
|| this.availableModels.find(m => m.id === (stream.sourceModel || context.model))?.name
|| stream.sourceModel
|| context.model
|| '?';
const summary = parts.length ? `${modelLabel} · ${parts.join(', ')}` : modelLabel;
perfEntries.push({ t: '', text: summary, icon: null });
perfEntries.push(...stream.statusLog);
if (perfEntries.length > 0) {
msg._perfLog = perfEntries;
msg._perfTotal = totalTime + 's total';
}
const msgStats = this.buildStreamRecentStats(stream, totalTime);
msg._recentStats = msgStats;
if (this.currentChatId === context.chatId) {
this.recentStats = { ...msgStats };
}
if (stream.streamingThinking) {
this.attachThinkingToAssistantMessage(msg, stream.streamingThinking);
}
if (stream._lastUsage) {
if (!msg.meta) {
msg.meta = this.captureGenerationContext(
msg.model || context.model,
context.systemPrompt,
context._profile
);
}
msg.meta.usage = this.cloneData(stream._lastUsage);
}
if (this.currentChatId === context.chatId) {
this.applyVariantStats(msg);
}
}
// Persist perf timeline that was just attached (saved earlier, before finally ran)
this.saveCurrentChat(context.chatId, chatSession.messages, context.model, context.systemPrompt);
}
this.resetStreamSession(stream, { preserveFinalContent: true });
this.thinkingAutoScroll = true;
if (depth === 0) {
this.stopStatsPollingIfIdle();
this.ensureStatsPollingForCurrentChat();
}
}
},
stopStreaming() {
if (this.currentChatId) {
this.stopChatStreaming(this.currentChatId);
}
},
async regenerateMessage(index) {
if (this.isCurrentChatStreaming()) return;
const session = this.getChatSession(this.currentChatId, true);
if (!session) return;
const origMsg = session.messages[index];
if (!origMsg || origMsg.role !== 'assistant' || origMsg._ui === false) return;
const userIdx = this.findUserIndexForMessage(session.messages, index);
const profileToUse = this.resolveStreamProfile(null, session);
this.messages = session.messages;
this.scheduleEnhanceMessages();
await this.streamResponse({
chatId: this.currentChatId,
model: this.currentModel,
systemPrompt: this.systemPrompt,
_profile: profileToUse,
_variantUserIndex: userIdx,
_excludeVariantsAtUserIndex: this.getTurnVariantsAt(session.messages, userIdx).length > 0
? userIdx : null,
}, 0);
},
_copyFallback(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} catch (err) {
console.error('Failed to copy:', err);
}
document.body.removeChild(textarea);
},
_copyText(text) {
return new Promise((resolve) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(resolve).catch(() => {
this._copyFallback(text);
resolve();
});
} else {
this._copyFallback(text);
resolve();
}
});
},
copyMessage(content) {
const raw = typeof content === 'string' ? content : this.getTextContent(content);
const text = typeof content === 'string'
? this.normalizeCopyText(this.extractThinking(raw).cleanText)
: this.normalizeCopyText(raw);
this._copyText(text);
},
copyMarkdown(msg) {
const text = this.getCopyableMarkdown(msg);
if (!text) {
const hasImages = Array.isArray(msg?.content)
&& msg.content.some(p => p.type === 'image_url');
if (hasImages) {
this._copyText(this.getTextContent(msg.content));
}
return;
}
this._copyText(text);
},
deleteMessage(index) {
if (this.isCurrentChatStreaming()) return;
const session = this.getChatSession(this.currentChatId, true);
if (!session) return;
const chatId = this.currentChatId;
session.messages = this.sliceBeforeMessage(session.messages, index);
if (session.messages.length === 0) {
this.deleteChat(chatId, { skipConfirm: true });
return;
}
this.messages = session.messages;
this.syncFooterStatsFromMessages(session.messages);
this.saveCurrentChat(chatId);
this.scheduleEnhanceMessages();
},
deleteVariant(userIndex, variantId, event) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
if (this.isCurrentChatStreaming()) return;
const session = this.getChatSession(this.currentChatId, true);
if (!session?.messages[userIndex] || session.messages[userIndex].role !== 'user') return;
const turnMsgs = [];
for (let i = userIndex + 1; i < session.messages.length && session.messages[i].role !== 'user'; i++) {
turnMsgs.push(session.messages[i]);
}
const segment = this.splitTurnSegments(turnMsgs).find(s => s.some(m => m.id === variantId));
if (!segment) return;
const removeIds = new Set(segment.map(m => m.id));
session.messages = session.messages.filter(m => !removeIds.has(m.id));
const user = session.messages[userIndex];
const remaining = this.getTurnVariantsAt(session.messages, userIndex);
if (remaining.length) {
const activeId = user._activeVariantId;
const next = remaining.find(v => v.id === activeId) || remaining[remaining.length - 1];
user._activeVariantId = next.id;
this.applyVariantStats(next);
} else {
delete user._activeVariantId;
this.recentStats = this.emptyStats();
}
this.messages = session.messages;
this.saveCurrentChat(this.currentChatId);
this.scheduleEnhanceMessages();
},
_setActiveVariant(session, assistantId, userIndex = null) {
let u = userIndex;
if (u == null || session.messages[u]?.role !== 'user') {
const aIdx = session.messages.findIndex(m => m.id === assistantId);
u = aIdx >= 0 ? this.findUserIndexForMessage(session.messages, aIdx) : -1;
}
if (u >= 0) {
session.messages[u]._activeVariantId = assistantId;
}
},
ensureVariantMeta(msg, session = null) {
if (msg.role !== 'assistant') return;
const systemPrompt = (typeof session === 'string' ? session : session?.systemPrompt) || '';
if (!msg.meta) {
msg.meta = this.captureGenerationContext(msg.model, systemPrompt, msg._profile);
} else if (!String(msg.meta.systemPrompt || '').trim() && String(systemPrompt).trim()) {
msg.meta.systemPrompt = systemPrompt.trim();
}
this.attachProfileToAssistantMessage(msg, this.resolveMessageProfile(msg));
},
branchChat(index) {
if (this.isCurrentChatStreaming()) return;
const session = this.getChatSession(this.currentChatId, true);
const originalChat = this.chatHistory.find(c => c.id === this.currentChatId);
const branchName = 'Branch of ' + (originalChat?.title || this.untitledChatTitle());
const branchId = 'chat_' + Date.now();
const branchMessages = session.messages.slice(0, index + 1).map(msg => this.cloneData(msg));
this.ensureMessageIds(branchMessages);
// Use profile from the message at branch point
const branchMsg = branchMessages[index];
const branchProfile = branchMsg?._profile ?? session.activeProfile ?? null;
const branchSession = {
messages: branchMessages,
model: session.model || this.currentModel,
systemPrompt: session.systemPrompt || '',
activeProfile: branchProfile,
modelSettings: session.modelSettings ? this.cloneData(session.modelSettings) : null,
speculativeEngine: session.speculativeEngine ?? null,
speculativeDraftModel: session.speculativeDraftModel ?? null,
modelSettingsByModel: session.modelSettingsByModel
? this.cloneData(session.modelSettingsByModel) : {},
};
this.chatSessions[branchId] = branchSession;
const messagesForStorage = branchMessages.map(msg => {
if (!Array.isArray(msg.content)) return msg;
return {
...msg,
content: msg.content.map(part => {
if (part.type === 'image_url') {
return { type: 'image_url', image_url: { url: '' } };
}
if (part.type === 'file') {
return {
type: 'file',
file: {
filename: part.file?.filename || '',
mime_type: part.file?.mime_type || '',
data: '',
},
};
}
return part;
})
};
});
const branchData = {
id: branchId,
title: branchName,
manualTitle: true,
messages: messagesForStorage,
model: branchSession.model,
systemPrompt: branchSession.systemPrompt,
activeProfile: branchSession.activeProfile,
modelSettings: branchSession.modelSettings,
speculativeEngine: branchSession.speculativeEngine,
speculativeDraftModel: branchSession.speculativeDraftModel,
modelSettingsByModel: this.cloneData(branchSession.modelSettingsByModel || {}),
updatedAt: new Date().toISOString()
};
this.chatHistory.unshift(branchData);
this.saveChatHistory();
this.loadChat(branchId);
},
computeTimelineDots() {
const container = document.getElementById('messagesContainer');
if (!container || this.messages.length === 0) {
this.timelineDots = [];
return;
}
const railHeight = container.clientHeight;
const visible = container.querySelectorAll('.message-fade-in');
const scrollMax = container.scrollHeight - railHeight;
const result = [];
visible.forEach((el) => {
if (el.offsetParent === null) return;
const msgIndex = parseInt(el.dataset.msgIndex, 10);
if (Number.isNaN(msgIndex)) return;
const msg = this.messages[msgIndex];
if (!msg || msg.role !== 'user') return;
const msgTop = el.offsetTop;
const ratio = scrollMax > 0 ? Math.min(msgTop / scrollMax, 1) : 0;
result.push({ index: msgIndex, topPx: 16 + (ratio * (railHeight - 32)) });
});
this.timelineDots = result;
},
computeTimelineActive() {
const container = document.getElementById('messagesContainer');
if (!container || this.messages.length === 0) return;
const scrollTop = container.scrollTop;
const visible = container.querySelectorAll('.message-fade-in');
let active = this.timelineActive;
visible.forEach((el) => {
if (el.offsetParent === null) return;
const msgIndex = parseInt(el.dataset.msgIndex, 10);
if (Number.isNaN(msgIndex)) return;
const msg = this.messages[msgIndex];
if (!msg || msg.role !== 'user') return;
const offset = el.offsetTop;
if (offset <= scrollTop + 60) {
active = msgIndex;
}
});
this.timelineActive = active;
},
showDotTooltip(event, msg, index) {
const excerpt = this.getTextContent(msg.content)?.slice(0, 120) || '';
const userIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle;margin-right:4px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
const botIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle;margin-right:4px;"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/><path d="M15 2v2"/><path d="M9 2v2"/><path d="M15 20v2"/><path d="M9 20v2"/></svg>';
const icon = msg.role === 'user' ? userIcon : botIcon;
this.timelineTooltip.content = `<span style="font-size:11px;">${icon}${this.escapeHtml(excerpt)}${excerpt.length >= 120 ? '...' : ''}</span>`;
const rect = event.currentTarget.getBoundingClientRect();
this.timelineTooltip.x = rect.right + 8;
this.timelineTooltip.y = rect.top + rect.height / 2 - 16;
this.timelineTooltip.show = true;
},
hideDotTooltip() {
this.timelineTooltip.show = false;
},
scrollToMessage(index) {
this.timelineActive = index;
const container = document.getElementById('messagesContainer');
if (!container) return;
const target = container.querySelector(`.message-fade-in[data-msg-index="${index}"]`);
if (target && target.offsetParent !== null) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
},
// Edit functions
startEdit(index) {
if (this.isCurrentChatStreaming() || this.editingIndex !== null) return;
this.editingIndex = index;
this.editContent = this.getTextContent(this.messages[index].content);
// Preserve images from the original message
this.editImages = this.getImageUrls(this.messages[index].content);
},
cancelEdit() {
this.editingIndex = null;
this.editContent = '';
this.editImages = [];
},
async saveEdit(index) {
if (!this.editContent.trim() || this.isCurrentChatStreaming()) return;
const session = this.getChatSession(this.currentChatId, true);
if (!session) return;
const newContent = this.editContent.trim();
const editImages = [...this.editImages];
// Remove all messages from this index onwards
session.messages = session.messages.slice(0, index);
this.messages = session.messages;
this.scheduleEnhanceMessages();
// Build message content, preserving images
let content;
if (editImages.length > 0) {
content = [];
for (const img of editImages) {
content.push({ type: 'image_url', image_url: { url: img } });
}
content.push({ type: 'text', text: newContent });
} else {
content = newContent;
}
// Add edited user message
session.messages.push({
id: this.newMessageId(),
role: 'user',
content
});
// Reset edit state before streaming (avoids leaking images if stream throws)
this.editingIndex = null;
this.editContent = '';
this.editImages = [];
// Scroll and get new response
this.scrollToBottom();
// Use profile from last assistant message before edit, or current active profile
const lastAsstBeforeEdit = [...session.messages.slice(0, index)].reverse().find(m => m.role === 'assistant' && m._ui !== false);
const editProfile = lastAsstBeforeEdit?._profile ?? this.activePromptProfile ?? null;
const variantUserIndex = session.messages.length - 1;
try {
await this.streamResponse({
chatId: this.currentChatId,
model: this.currentModel,
systemPrompt: this.systemPrompt,
_profile: editProfile,
_variantUserIndex: variantUserIndex,
}, 0);
} catch (e) {
console.error('saveEdit stream failed:', e);
}
},
// Delete chat
async deleteChat(chatId, { skipConfirm = false } = {}) {
if (!skipConfirm && !confirm(window.t('chat.confirm_delete_chat'))) return;
this.getStreamSession(chatId, false)?.abortController?.abort();
delete this.streamSessions[chatId];
delete this.chatSessions[chatId];
const wasCurrent = this.currentChatId === chatId;
this.chatHistory = this.chatHistory.filter(c => c.id !== chatId);
this.saveChatHistory();
if (!wasCurrent) return;
this.cancelPersistModelSettingsTimer();
this.currentChatId = null;
this.messages = [];
this.inputMessage = '';
this.uploadImages = [];
this.uploadDocuments = [];
this.editingIndex = null;
this.editContent = '';
this.editImages = [];
this.recentStats = this.emptyStats();
this.stopStatsPollingIfIdle();
if (this.chatHistory.length > 0) {
await this.loadChat(this.chatHistory[0].id);
return;
}
const sysDefault = this.promptProfiles.find(p => p.name === 'System Default');
this.systemPrompt = sysDefault ? sysDefault.content : '';
this.activePromptProfile = sysDefault ? sysDefault.name : null;
this.$nextTick(() => {
const container = document.getElementById('messagesContainer');
if (container) container.scrollTop = 0;
this.computeTimelineDots();
this.computeTimelineActive();
this.$refs.messageInput?.focus();
});
},
// Clear all history
async clearAllHistory() {
if (!confirm(window.t('chat.confirm_clear_all'))) return;
this.stopAllStreams();
this.chatHistory = [];
this.chatSessions = {};
this.streamSessions = {};
this.saveChatHistory();
await this.startNewChat();
},
// Thinking/Reasoning tag processing
escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
},
extractThinking(text) {
const thinkingRegex = /<think>([\s\S]*?)<\/think(?:\s+data-elapsed="(\d+)")?>/g;
const thinkingBlocks = [];
let cleanText = text;
let match;
while ((match = thinkingRegex.exec(text)) !== null) {
thinkingBlocks.push({
content: match[1].trim(),
elapsed: match[2] ? parseInt(match[2]) : null,
placeholder: `THINKINGPLACEHOLDER${thinkingBlocks.length}`
});
}
// Remove thinking tags from the main text
cleanText = text.replace(thinkingRegex, '').trim();
return { thinkingBlocks, cleanText };
},
renderThinkingBlock(content, index, messageId, elapsed) {
let thinkingTime;
if (elapsed != null) {
thinkingTime = (elapsed / 1000).toFixed(1);
} else {
// Fallback estimate for history reload (no timing data)
const wordsPerSecond = 3;
const wordCount = content.split(/\s+/).length;
thinkingTime = Math.max(1, Math.round(wordCount / wordsPerSecond));
}
const uniqueId = `think_${++this._thinkingDomSeq}`;
return `
<div class="thinking-container">
<div class="thinking-header" onclick="document.getElementById('thinking-content-${uniqueId}').classList.toggle('collapsed'); document.getElementById('thinking-toggle-${uniqueId}').classList.toggle('collapsed');">
<svg class="thinking-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/>
<path d="M10 6v4l2.5 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="thinking-text">Thought for ${thinkingTime} seconds</span>
<svg class="thinking-toggle" id="thinking-toggle-${uniqueId}" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="thinking-content collapsed" id="thinking-content-${uniqueId}">
${DOMPurify.sanitize(marked.parse(content))}
</div>
</div>
`;
},
renderMarkdown(text) {
if (!text) return '';
try {
// Extract thinking blocks first
const { thinkingBlocks, cleanText } = this.extractThinking(text);
// Parse clean text with marked and sanitize
let html = DOMPurify.sanitize(marked.parse(cleanText));
// Add thinking blocks at the beginning if they exist
if (thinkingBlocks.length > 0) {
const thinkingHtml = thinkingBlocks.map((block, index) =>
this.renderThinkingBlock(block.content, index, null, block.elapsed)
).join('');
html = thinkingHtml + html;
}
return html;
} catch (e) {
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
},
// Render markdown for streaming content (pure HTML generator, no side effects)
renderStreamingMarkdown(text) {
if (!text) return '';
try {
let html = '';
let remaining = text;
// Process complete think blocks first
const completeThinkRegex = /<think>([\s\S]*?)<\/think(?:\s+data-elapsed="(\d+)")?>/g;
let lastIndex = 0;
let match;
let blockIndex = 0;
while ((match = completeThinkRegex.exec(text)) !== null) {
const beforeContent = text.substring(lastIndex, match.index);
if (beforeContent.trim()) {
html += DOMPurify.sanitize(marked.parse(beforeContent));
}
const elapsed = match[2] ? parseInt(match[2]) : null;
html += this.renderThinkingBlock(match[1].trim(), blockIndex++, 'streaming', elapsed);
lastIndex = match.index + match[0].length;
}
remaining = text.substring(lastIndex);
const incompleteThinkIndex = remaining.indexOf('<think>');
if (incompleteThinkIndex !== -1) {
const beforeIncomplete = remaining.substring(0, incompleteThinkIndex);
if (beforeIncomplete.trim()) {
html += DOMPurify.sanitize(marked.parse(beforeIncomplete));
}
const thinkingContent = remaining.substring(incompleteThinkIndex + '<think>'.length);
const streamThinkId = `think_${++this._thinkingDomSeq}`;
html += `
<div class="thinking-container" id="streaming-thinking-container">
<div class="thinking-header" onclick="document.getElementById('thinking-content-${streamThinkId}').classList.toggle('collapsed'); document.getElementById('thinking-toggle-${streamThinkId}').classList.toggle('collapsed');">
<svg class="thinking-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/>
<path d="M10 6v4l2.5 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="thinking-text">Thinking...</span>
<svg class="thinking-toggle collapsed" id="thinking-toggle-${streamThinkId}" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="thinking-content collapsed" id="thinking-content-${streamThinkId}" style="white-space: pre-wrap;">
${this.escapeHtml(thinkingContent)}
</div>
</div>
`;
} else if (remaining.trim()) {
html += DOMPurify.sanitize(marked.parse(remaining));
}
return html;
} catch (e) {
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
},
// Efficiently update streaming DOM without destroying thinking container
updateStreamingDOM(_contentTick) {
const stream = this.currentStream();
if (!stream?.isStreaming) return;
if (stream._domUpdateRaf) cancelAnimationFrame(stream._domUpdateRaf);
stream._domUpdateRaf = requestAnimationFrame(() => {
stream._domUpdateRaf = null;
this._updateStreamingDOMImpl();
});
},
_updateStreamingDOMImpl() {
const container = this.$refs.streamingOutput;
const stream = this.currentStream();
if (!container) return;
// The stable/tail roots are a single shared DOM node across chats.
// If we last painted a different chat, wipe and re-render from scratch
// so the previous chat's stable portion doesn't leak into this one.
const activeChat = this.currentChatId || '';
if (container.dataset.streamChatId !== activeChat) {
container.innerHTML = '';
this.resetStreamRenderState(stream);
container.dataset.streamChatId = activeChat;
}
if (!stream?.streamingContent) {
container.innerHTML = '';
this.resetStreamRenderState(stream);
return;
}
const text = stream.streamingContent;
if (text.includes('<think>')) {
container.innerHTML = this.renderStreamingMarkdown(text);
this.$nextTick(() => {
this.addCodeCopyButtons(container);
this.renderMathInRoot(container);
this.scrollToBottom();
});
return;
}
const { stableRoot, tailRoot } = this.ensureStreamingRoots(container, stream);
const cutoff = this.findStableRenderCutoff(text);
const safeCutoff = Math.max(cutoff, stream.streamRenderState.stableIndex);
if (safeCutoff > stream.streamRenderState.stableIndex) {
stream.streamRenderState.stableText = text.slice(0, safeCutoff);
stableRoot.innerHTML = stream.streamRenderState.stableText
? DOMPurify.sanitize(marked.parse(stream.streamRenderState.stableText))
: '';
this.addCodeCopyButtons(stableRoot);
this.renderMathInRoot(stableRoot);
stream.streamRenderState.stableIndex = safeCutoff;
}
const tailText = text.slice(stream.streamRenderState.stableIndex);
tailRoot.innerHTML = tailText ? DOMPurify.sanitize(marked.parse(tailText)) : '';
this.$nextTick(() => {
this.scrollToBottom();
});
},
ensureStreamingRoots(container, stream = this.currentStream()) {
let stableRoot = container.querySelector('[data-streaming-stable-root]');
let tailRoot = container.querySelector('[data-streaming-tail-root]');
if (!stableRoot || !tailRoot) {
this.resetStreamingRoots(container, stream);
stableRoot = container.querySelector('[data-streaming-stable-root]');
tailRoot = container.querySelector('[data-streaming-tail-root]');
}
return { stableRoot, tailRoot };
},
resetStreamingRoots(container, stream = this.currentStream()) {
container.innerHTML = `
<div data-streaming-stable-root="true"></div>
<div data-streaming-tail-root="true"></div>
`;
this.resetStreamRenderState(stream);
},
resetStreamRenderState(stream = this.currentStream()) {
if (!stream) return;
stream.streamRenderState.stableIndex = 0;
stream.streamRenderState.stableText = '';
},
setAllowSvg(enabled) {
this.allowSvg = !!enabled;
localStorage.setItem('omlx-chat-allow-svg', this.allowSvg ? 'true' : 'false');
this.refreshCodeBlockButtons();
},
refreshCodeBlockButtons() {
const container = document.getElementById('messagesContainer');
if (!container) return;
container.querySelectorAll('.svg-preview-container').forEach(preview => {
const wrapper = preview.parentElement;
const pre = wrapper?.querySelector('pre');
if (pre) pre.style.display = '';
const copyBtn = wrapper?.querySelector('.code-copy-btn');
if (copyBtn) copyBtn.style.display = '';
preview.remove();
});
container.querySelectorAll('.code-btn-row').forEach(row => row.remove());
this.addCodeCopyButtons(container);
},
addCodeCopyButtons(root = null) {
const container = root || document.getElementById('messagesContainer');
if (!container) return;
const codeBlocks = container.querySelectorAll('pre code');
codeBlocks.forEach(codeBlock => {
const pre = codeBlock.parentElement;
// Wrap in container if not already
if (!pre.parentElement.classList.contains('code-block-wrapper')) {
const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper';
pre.parentElement.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
}
// Check if button row already exists
if (pre.parentElement.querySelector('.code-btn-row')) return;
// Detect SVG code blocks
const rawText = codeBlock.textContent;
const isSvg = /^\s*(?:<\?xml[^?]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)?<svg[\s>]/i.test(rawText);
// Create button row
const btnRow = document.createElement('div');
btnRow.className = 'code-btn-row';
// Create SVG render button (only for SVG blocks when allowed)
if (isSvg && this.allowSvg) {
const wandIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8 19 13"/><path d="M15 9h.01"/><path d="M17.8 6.2 19 5"/><path d="m3 21 9-9"/><path d="M12.2 6.2 11 5"/></svg>';
const renderBtn = document.createElement('button');
renderBtn.className = 'svg-render-btn';
renderBtn.innerHTML = wandIcon + ' Render';
renderBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
const wrapper = pre.parentElement;
const copyBtn = wrapper.querySelector('.code-copy-btn');
let preview = wrapper.querySelector('.svg-preview-container');
if (preview) {
// Toggle back to code view
preview.remove();
pre.style.display = '';
if (copyBtn) copyBtn.style.display = '';
renderBtn.innerHTML = wandIcon + ' Render';
return;
}
const sanitized = DOMPurify.sanitize(rawText, {
ADD_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse', 'g', 'defs', 'clipPath', 'text', 'tspan', 'symbol', 'linearGradient', 'radialGradient', 'stop', 'pattern', 'mask', 'title', 'desc'],
ADD_ATTR: ['viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'transform', 'clip-path', 'opacity', 'gradientUnits', 'gradientTransform', 'offset', 'stop-color', 'stop-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'width', 'height', 'preserveAspectRatio', 'filter', 'flood-color', 'flood-opacity', 'in', 'in2', 'type', 'values', 'stdDeviation', 'result', 'patternTransform', 'patternUnits', 'spreadMethod', 'fx', 'fy'],
});
if (!sanitized || !sanitized.includes('<svg')) return;
preview = document.createElement('div');
preview.className = 'svg-preview-container';
preview.innerHTML = sanitized;
pre.style.display = 'none';
if (copyBtn) copyBtn.style.display = 'none';
wrapper.appendChild(preview);
renderBtn.innerHTML = '\u2190 Back';
};
btnRow.appendChild(renderBtn);
}
// Create copy button
const copyIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
const copyBtn = document.createElement('button');
copyBtn.className = 'code-copy-btn';
copyBtn.innerHTML = copyIcon + ' Copy';
copyBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this._copyText(codeBlock.textContent).then(() => {
copyBtn.innerHTML = copyIcon + ' Copied!';
setTimeout(() => copyBtn.innerHTML = copyIcon + ' Copy', 2000);
});
};
btnRow.appendChild(copyBtn);
pre.parentElement.appendChild(btnRow);
});
},
renderMathInRoot(root, retries = 0) {
if (!root) return;
if (typeof renderMathInElement === 'undefined') {
if (retries < 20) {
setTimeout(() => this.renderMathInRoot(root, retries + 1), 100);
}
return;
}
try {
renderMathInElement(root, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '\\(', right: '\\)', display: false }
],
throwOnError: false,
strict: false,
ignoredTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'],
ignoredClasses: ['hljs']
});
} catch (e) {
console.error('Math rendering error:', e);
}
},
renderPendingMath(root = null) {
const container = root || document.getElementById('messagesContainer');
if (!container) return;
const targets = container.querySelectorAll('[data-math-pending="true"]');
targets.forEach((element) => {
const version = element.dataset.mathVersion || '0';
if (element.dataset.mathRenderedVersion === version) return;
this.renderMathInRoot(element);
element.dataset.mathRenderedVersion = version;
});
},
scheduleEnhanceMessages() {
this.$nextTick(() => {
const container = document.getElementById('messagesContainer');
if (!container) return;
this.addCodeCopyButtons(container);
this.renderPendingMath(container);
this.computeTimelineDots();
this.computeTimelineActive();
});
},
_isMarkdownCharEscaped(text, index) {
let slashes = 0;
for (let j = index - 1; j >= 0 && text[j] === '\\'; j--) slashes++;
return slashes % 2 === 1;
},
findStableRenderCutoff(text) {
if (!text) return 0;
let inFence = false;
let inInlineCode = false;
let inInlineMath = false;
let inDisplayMath = false;
let inParenMath = false;
let inBracketMath = false;
let lastSafeBoundary = 0;
let lastWasNewline = false;
let lastPromotedBoundary = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const nextTwo = text.slice(i, i + 2);
const escaped = this._isMarkdownCharEscaped(text, i);
if (!inInlineMath && !inDisplayMath && !inParenMath && !inBracketMath && ch === '`' && !escaped) {
let tickCount = 0;
while (i + tickCount < text.length && text[i + tickCount] === '`') tickCount++;
if (tickCount >= 3) {
inFence = !inFence;
i += tickCount - 1;
lastWasNewline = false;
continue;
}
if (!inFence && tickCount === 1) {
inInlineCode = !inInlineCode;
lastWasNewline = false;
continue;
}
}
if (!inFence && !inInlineCode) {
if (!inInlineMath && !inParenMath && !inBracketMath && nextTwo === '$$' && !escaped) {
inDisplayMath = !inDisplayMath;
i += 1;
lastWasNewline = false;
continue;
}
if (!inDisplayMath && !inInlineMath && !inBracketMath && nextTwo === '\\(' && !escaped) {
inParenMath = true;
i += 1;
lastWasNewline = false;
continue;
}
if (inParenMath && nextTwo === '\\)' && !escaped) {
inParenMath = false;
i += 1;
lastWasNewline = false;
continue;
}
if (!inDisplayMath && !inInlineMath && !inParenMath && nextTwo === '\\[' && !escaped) {
inBracketMath = true;
i += 1;
lastWasNewline = false;
continue;
}
if (inBracketMath && nextTwo === '\\]' && !escaped) {
inBracketMath = false;
i += 1;
lastWasNewline = false;
continue;
}
if (!inDisplayMath && !inParenMath && !inBracketMath && ch === '$' && !escaped) {
inInlineMath = !inInlineMath;
lastWasNewline = false;
continue;
}
}
const balanced = !inFence && !inInlineCode && !inInlineMath && !inDisplayMath && !inParenMath && !inBracketMath;
if (balanced) {
if (ch === '\n' && lastWasNewline) {
lastSafeBoundary = i + 1;
lastPromotedBoundary = lastSafeBoundary;
} else if ((ch === '.' || ch === '!' || ch === '?' || ch === '\n') && (i + 1 - lastPromotedBoundary) >= 240) {
lastSafeBoundary = i + 1;
lastPromotedBoundary = lastSafeBoundary;
}
}
lastWasNewline = ch === '\n';
}
return lastSafeBoundary;
},
// Scroll to bottom (respects autoScrollEnabled unless forced)
scrollToBottom(force = false) {
this.$nextTick(() => {
const container = document.getElementById('messagesContainer');
if (!container) return;
// Only scroll if auto-scroll is enabled or forced
if (!force && !this.autoScrollEnabled) return;
container.scrollTo({
top: container.scrollHeight,
behavior: force ? 'auto' : 'smooth'
});
});
},
// Force scroll to bottom and re-enable auto-scroll
forceScrollToBottom() {
this.autoScrollEnabled = true;
this.scrollToBottom(true);
},
autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 128) + 'px';
},
resetTextareaHeight(el) {
if (!el) return;
el.style.height = 'auto';
},
setTheme(theme) {
this.theme = theme;
localStorage.setItem('omlx-chat-theme', this.theme);
this.applyTheme();
},
async checkForUpdate() {
try {
const resp = await fetch('/admin/api/update-check');
if (resp.ok) {
const data = await resp.json();
this.updateAvailable = data.update_available;
this.latestVersion = data.latest_version;
this.releaseUrl = data.release_url;
}
} catch (e) {
// Silently ignore - not critical
}
},
startUpdateCheckTimer() {
this._updateCheckTimer = setInterval(() => this.checkForUpdate(), 3600000);
},
applyTheme() {
// Clean up existing listener
if (this.systemThemeListener) {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.systemThemeListener);
this.systemThemeListener = null;
}
if (this.theme === 'auto') {
// Detect system theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
this.activeTheme = prefersDark ? 'dark' : 'light';
// Add listener for system theme changes
this.systemThemeListener = (e) => {
this.activeTheme = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', this.activeTheme);
const lightTheme = document.getElementById('hljs-light-theme');
const darkTheme = document.getElementById('hljs-dark-theme');
if (lightTheme && darkTheme) {
lightTheme.disabled = this.activeTheme === 'dark';
darkTheme.disabled = this.activeTheme === 'light';
}
};
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.systemThemeListener);
} else {
// Use explicit theme
this.activeTheme = this.theme;
}
document.documentElement.setAttribute('data-theme', this.activeTheme);
const lightTheme = document.getElementById('hljs-light-theme');
const darkTheme = document.getElementById('hljs-dark-theme');
if (lightTheme && darkTheme) {
lightTheme.disabled = this.activeTheme === 'dark';
darkTheme.disabled = this.activeTheme === 'light';
}
}
};
}
</script>
{% endblock %}