6166 lines
272 KiB
HTML
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);">✓</span>
|
|
<span x-show="entry.icon === 'error'"
|
|
style="color:var(--text-danger);">✗</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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
},
|
|
|
|
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, '<').replace(/>/g, '>');
|
|
}
|
|
},
|
|
|
|
// 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, '<').replace(/>/g, '>');
|
|
}
|
|
},
|
|
|
|
// 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 %}
|