Upload 15 files
Browse files- assets/css/styles.css +143 -2
- assets/js/script.js +141 -14
- assets/js/utils.js +19 -2
- index.html +39 -10
assets/css/styles.css
CHANGED
|
@@ -1075,6 +1075,7 @@ body {
|
|
| 1075 |
.input-container {
|
| 1076 |
border-top: 1px solid var(--color-border);
|
| 1077 |
padding: 16px 24px;
|
|
|
|
| 1078 |
background-color: var(--color-surface);
|
| 1079 |
}
|
| 1080 |
|
|
@@ -1561,6 +1562,16 @@ body {
|
|
| 1561 |
background: var(--color-border-hover);
|
| 1562 |
}
|
| 1563 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1564 |
/* Collapsible Sidebar Sections */
|
| 1565 |
.section-collapsible {
|
| 1566 |
border: 1px solid var(--color-border);
|
|
@@ -1626,7 +1637,7 @@ body {
|
|
| 1626 |
text-align: center;
|
| 1627 |
font-size: 12px;
|
| 1628 |
color: var(--color-text-tertiary);
|
| 1629 |
-
z-index:
|
| 1630 |
}
|
| 1631 |
|
| 1632 |
.app-footer a {
|
|
@@ -1647,7 +1658,7 @@ body {
|
|
| 1647 |
|
| 1648 |
/* Adjust main content to account for footer */
|
| 1649 |
.main-content {
|
| 1650 |
-
padding-bottom
|
| 1651 |
}
|
| 1652 |
|
| 1653 |
/* ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1802,3 +1813,133 @@ body {
|
|
| 1802 |
.reset-cost-btn:active {
|
| 1803 |
transform: translateY(0);
|
| 1804 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1075 |
.input-container {
|
| 1076 |
border-top: 1px solid var(--color-border);
|
| 1077 |
padding: 16px 24px;
|
| 1078 |
+
padding-bottom: 56px; /* Account for fixed footer */
|
| 1079 |
background-color: var(--color-surface);
|
| 1080 |
}
|
| 1081 |
|
|
|
|
| 1562 |
background: var(--color-border-hover);
|
| 1563 |
}
|
| 1564 |
|
| 1565 |
+
/* Firefox scrollbar styling */
|
| 1566 |
+
* {
|
| 1567 |
+
scrollbar-width: thin;
|
| 1568 |
+
scrollbar-color: var(--color-border) transparent;
|
| 1569 |
+
}
|
| 1570 |
+
|
| 1571 |
+
*:hover {
|
| 1572 |
+
scrollbar-color: var(--color-border-hover) transparent;
|
| 1573 |
+
}
|
| 1574 |
+
|
| 1575 |
/* Collapsible Sidebar Sections */
|
| 1576 |
.section-collapsible {
|
| 1577 |
border: 1px solid var(--color-border);
|
|
|
|
| 1637 |
text-align: center;
|
| 1638 |
font-size: 12px;
|
| 1639 |
color: var(--color-text-tertiary);
|
| 1640 |
+
z-index: 50;
|
| 1641 |
}
|
| 1642 |
|
| 1643 |
.app-footer a {
|
|
|
|
| 1658 |
|
| 1659 |
/* Adjust main content to account for footer */
|
| 1660 |
.main-content {
|
| 1661 |
+
/* Footer space handled by input-container padding-bottom */
|
| 1662 |
}
|
| 1663 |
|
| 1664 |
/* ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
| 1813 |
.reset-cost-btn:active {
|
| 1814 |
transform: translateY(0);
|
| 1815 |
}
|
| 1816 |
+
/* ========================================
|
| 1817 |
+
Scroll to Bottom Button
|
| 1818 |
+
======================================== */
|
| 1819 |
+
.scroll-to-bottom-btn {
|
| 1820 |
+
position: absolute;
|
| 1821 |
+
bottom: 100px;
|
| 1822 |
+
right: 24px;
|
| 1823 |
+
width: 40px;
|
| 1824 |
+
height: 40px;
|
| 1825 |
+
border-radius: 50%;
|
| 1826 |
+
background: var(--color-surface);
|
| 1827 |
+
border: 1px solid var(--color-border);
|
| 1828 |
+
color: var(--color-text-secondary);
|
| 1829 |
+
cursor: pointer;
|
| 1830 |
+
display: flex;
|
| 1831 |
+
align-items: center;
|
| 1832 |
+
justify-content: center;
|
| 1833 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 1834 |
+
transition: all 0.2s ease;
|
| 1835 |
+
z-index: 20;
|
| 1836 |
+
opacity: 1;
|
| 1837 |
+
transform: translateY(0);
|
| 1838 |
+
}
|
| 1839 |
+
|
| 1840 |
+
.scroll-to-bottom-btn:hover {
|
| 1841 |
+
background: var(--color-primary);
|
| 1842 |
+
border-color: var(--color-primary);
|
| 1843 |
+
color: white;
|
| 1844 |
+
transform: translateY(-2px);
|
| 1845 |
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
| 1846 |
+
}
|
| 1847 |
+
|
| 1848 |
+
.scroll-to-bottom-btn.hidden {
|
| 1849 |
+
opacity: 0;
|
| 1850 |
+
transform: translateY(20px);
|
| 1851 |
+
pointer-events: none;
|
| 1852 |
+
}
|
| 1853 |
+
|
| 1854 |
+
/* Midnight theme */
|
| 1855 |
+
[data-theme="midnight"] .scroll-to-bottom-btn {
|
| 1856 |
+
background: #1a0f28;
|
| 1857 |
+
border-color: #2a1a3d;
|
| 1858 |
+
box-shadow: 0 4px 15px rgba(168, 85, 247, 0.2);
|
| 1859 |
+
}
|
| 1860 |
+
|
| 1861 |
+
[data-theme="midnight"] .scroll-to-bottom-btn:hover {
|
| 1862 |
+
background: linear-gradient(135deg, #a855f7 0%, #7c3aed 100%);
|
| 1863 |
+
border-color: #a855f7;
|
| 1864 |
+
}
|
| 1865 |
+
|
| 1866 |
+
/* ========================================
|
| 1867 |
+
Focus Mode — Hide Sidebar on Desktop
|
| 1868 |
+
======================================== */
|
| 1869 |
+
.focus-mode-btn {
|
| 1870 |
+
position: absolute;
|
| 1871 |
+
top: 16px;
|
| 1872 |
+
right: 16px;
|
| 1873 |
+
z-index: 10;
|
| 1874 |
+
display: none; /* Hidden on mobile by default */
|
| 1875 |
+
align-items: center;
|
| 1876 |
+
justify-content: center;
|
| 1877 |
+
width: 36px;
|
| 1878 |
+
height: 36px;
|
| 1879 |
+
border: 1px solid var(--color-border);
|
| 1880 |
+
border-radius: 8px;
|
| 1881 |
+
background-color: var(--color-surface);
|
| 1882 |
+
color: var(--color-text-secondary);
|
| 1883 |
+
cursor: pointer;
|
| 1884 |
+
transition: all 0.2s;
|
| 1885 |
+
}
|
| 1886 |
+
|
| 1887 |
+
.focus-mode-btn:hover {
|
| 1888 |
+
background-color: var(--color-surface-secondary);
|
| 1889 |
+
color: var(--color-text);
|
| 1890 |
+
}
|
| 1891 |
+
|
| 1892 |
+
.focus-mode-btn .icon-collapse {
|
| 1893 |
+
display: none;
|
| 1894 |
+
}
|
| 1895 |
+
|
| 1896 |
+
.focus-mode-btn .icon-expand {
|
| 1897 |
+
display: block;
|
| 1898 |
+
}
|
| 1899 |
+
|
| 1900 |
+
/* When focus mode is active */
|
| 1901 |
+
body.focus-mode .focus-mode-btn .icon-expand {
|
| 1902 |
+
display: none;
|
| 1903 |
+
}
|
| 1904 |
+
|
| 1905 |
+
body.focus-mode .focus-mode-btn .icon-collapse {
|
| 1906 |
+
display: block;
|
| 1907 |
+
}
|
| 1908 |
+
|
| 1909 |
+
body.focus-mode .focus-mode-btn {
|
| 1910 |
+
background-color: var(--color-primary);
|
| 1911 |
+
border-color: var(--color-primary);
|
| 1912 |
+
color: white;
|
| 1913 |
+
}
|
| 1914 |
+
|
| 1915 |
+
/* Desktop: Show focus button and handle sidebar hiding */
|
| 1916 |
+
@media (min-width: 1025px) {
|
| 1917 |
+
.focus-mode-btn {
|
| 1918 |
+
display: flex;
|
| 1919 |
+
}
|
| 1920 |
+
|
| 1921 |
+
body.focus-mode .sidebar {
|
| 1922 |
+
transform: translateX(-100%);
|
| 1923 |
+
position: absolute;
|
| 1924 |
+
}
|
| 1925 |
+
|
| 1926 |
+
body.focus-mode .main-content {
|
| 1927 |
+
margin-left: 0;
|
| 1928 |
+
}
|
| 1929 |
+
|
| 1930 |
+
/* Smooth transition for sidebar */
|
| 1931 |
+
.sidebar {
|
| 1932 |
+
transition: transform 0.3s var(--transition-timing);
|
| 1933 |
+
}
|
| 1934 |
+
}
|
| 1935 |
+
|
| 1936 |
+
/* Midnight theme focus button */
|
| 1937 |
+
[data-theme="midnight"] .focus-mode-btn:hover {
|
| 1938 |
+
background: linear-gradient(135deg, #1a0f28 0%, #2a1a3d 100%);
|
| 1939 |
+
border-color: #a855f7;
|
| 1940 |
+
}
|
| 1941 |
+
|
| 1942 |
+
[data-theme="midnight"] body.focus-mode .focus-mode-btn {
|
| 1943 |
+
background: linear-gradient(135deg, #a855f7 0%, #7c3aed 100%);
|
| 1944 |
+
border-color: #a855f7;
|
| 1945 |
+
}
|
assets/js/script.js
CHANGED
|
@@ -70,7 +70,10 @@ const elements = {
|
|
| 70 |
presValue: document.getElementById("presValue"),
|
| 71 |
autoShowReasoningInput: document.getElementById("autoShowReasoning"),
|
| 72 |
contextLimitInput: document.getElementById("contextLimit"),
|
| 73 |
-
messageCounter: document.getElementById("messageCounter")
|
|
|
|
|
|
|
|
|
|
| 74 |
};
|
| 75 |
|
| 76 |
// NOTE: escapeHtml is defined in utils.js - use that global function
|
|
@@ -133,6 +136,11 @@ async function importConversation() {
|
|
| 133 |
|
| 134 |
// Load a conversation into the UI (called by onConversationChange callback)
|
| 135 |
function loadConversationIntoUI(conversation, messages) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
// Clear and load messages
|
| 137 |
elements.chatMessages.innerHTML = "";
|
| 138 |
|
|
@@ -160,7 +168,7 @@ function loadConversationIntoUI(conversation, messages) {
|
|
| 160 |
|
| 161 |
updateMessageCounter();
|
| 162 |
|
| 163 |
-
//
|
| 164 |
conversationsUI.refresh();
|
| 165 |
|
| 166 |
// Show notification when switching conversations (but not on initial load)
|
|
@@ -203,6 +211,7 @@ async function init() {
|
|
| 203 |
updateModelSelection();
|
| 204 |
updateReasoningSection();
|
| 205 |
checkApiKey();
|
|
|
|
| 206 |
conversationsUI.init();
|
| 207 |
await initializeConversations();
|
| 208 |
}
|
|
@@ -448,18 +457,102 @@ function setupEventListeners() {
|
|
| 448 |
sendMessage();
|
| 449 |
}
|
| 450 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
});
|
| 452 |
}
|
| 453 |
|
|
|
|
|
|
|
|
|
|
| 454 |
// Handle input change
|
| 455 |
function handleInputChange() {
|
| 456 |
-
//
|
| 457 |
-
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
| 459 |
|
| 460 |
updateSendButton();
|
| 461 |
}
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
// Check API key
|
| 464 |
function checkApiKey() {
|
| 465 |
if (!state.apiKey) {
|
|
@@ -790,10 +883,19 @@ function addMessage(role, content, reasoning = null, timestamp = null, messageId
|
|
| 790 |
|
| 791 |
// Delete a message from UI, state, and database
|
| 792 |
async function deleteMessage(messageDiv, content, role) {
|
| 793 |
-
//
|
| 794 |
-
const
|
| 795 |
-
|
| 796 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
}
|
| 798 |
|
| 799 |
// Remove from database if we have a messageId
|
|
@@ -985,6 +1087,11 @@ async function regenerateResponse(messageDiv) {
|
|
| 985 |
);
|
| 986 |
state.messages.push({ role: "assistant", content: fullContent });
|
| 987 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
try {
|
| 989 |
await saveMessageToDB("assistant", fullContent, reasoning);
|
| 990 |
} catch (error) {}
|
|
@@ -1179,6 +1286,12 @@ function finalizeStreamingMessage(messageDiv, textDiv, contentDiv, content, reas
|
|
| 1179 |
footerDiv.innerHTML = `
|
| 1180 |
<span class="message-time">${timeStr}</span>
|
| 1181 |
<div class="message-actions">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1182 |
<button class="msg-action-btn" data-action="copy" title="Copy">
|
| 1183 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1184 |
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
@@ -1194,6 +1307,11 @@ function finalizeStreamingMessage(messageDiv, textDiv, contentDiv, content, reas
|
|
| 1194 |
</div>
|
| 1195 |
`;
|
| 1196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1197 |
footerDiv.querySelector('[data-action="copy"]').addEventListener("click", () => {
|
| 1198 |
navigator.clipboard
|
| 1199 |
.writeText(content)
|
|
@@ -1267,10 +1385,10 @@ async function sendMessage() {
|
|
| 1267 |
if (chunk.reasoning) {
|
| 1268 |
reasoning = chunk.reasoning;
|
| 1269 |
}
|
| 1270 |
-
// Handle usage stats
|
| 1271 |
if (chunk.usage) {
|
| 1272 |
state.lastUsage = chunk.usage;
|
| 1273 |
-
updateTokenCounter();
|
| 1274 |
}
|
| 1275 |
});
|
| 1276 |
|
|
@@ -1279,6 +1397,11 @@ async function sendMessage() {
|
|
| 1279 |
finalizeStreamingMessage(messageDiv, textDiv, contentDiv, fullContent, reasoning, new Date().toISOString());
|
| 1280 |
state.messages.push({ role: "assistant", content: fullContent });
|
| 1281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1282 |
// Save to database
|
| 1283 |
try {
|
| 1284 |
await saveMessageToDB("assistant", fullContent, reasoning);
|
|
@@ -1347,15 +1470,19 @@ function updateSendButton() {
|
|
| 1347 |
}
|
| 1348 |
|
| 1349 |
// Update token counter display
|
| 1350 |
-
function updateTokenCounter() {
|
| 1351 |
const sessionCostEl = document.getElementById("sessionCostDisplay");
|
| 1352 |
const tokenUsageEl = document.getElementById("tokenUsageDisplay");
|
| 1353 |
|
| 1354 |
if (state.lastUsage) {
|
| 1355 |
const input = state.lastUsage.prompt_tokens || 0;
|
| 1356 |
const output = state.lastUsage.completion_tokens || 0;
|
| 1357 |
-
|
| 1358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1359 |
|
| 1360 |
const costStr =
|
| 1361 |
state.totalCost < 0.01 ? `${(state.totalCost * 100).toFixed(2)}¢` : `$${state.totalCost.toFixed(4)}`;
|
|
|
|
| 70 |
presValue: document.getElementById("presValue"),
|
| 71 |
autoShowReasoningInput: document.getElementById("autoShowReasoning"),
|
| 72 |
contextLimitInput: document.getElementById("contextLimit"),
|
| 73 |
+
messageCounter: document.getElementById("messageCounter"),
|
| 74 |
+
// New UI elements
|
| 75 |
+
scrollToBottomBtn: document.getElementById("scrollToBottomBtn"),
|
| 76 |
+
focusModeBtn: document.getElementById("focusModeBtn")
|
| 77 |
};
|
| 78 |
|
| 79 |
// NOTE: escapeHtml is defined in utils.js - use that global function
|
|
|
|
| 136 |
|
| 137 |
// Load a conversation into the UI (called by onConversationChange callback)
|
| 138 |
function loadConversationIntoUI(conversation, messages) {
|
| 139 |
+
// Reset session cost when switching conversations
|
| 140 |
+
state.totalCost = 0;
|
| 141 |
+
state.lastUsage = null;
|
| 142 |
+
updateTokenCounter(false);
|
| 143 |
+
|
| 144 |
// Clear and load messages
|
| 145 |
elements.chatMessages.innerHTML = "";
|
| 146 |
|
|
|
|
| 168 |
|
| 169 |
updateMessageCounter();
|
| 170 |
|
| 171 |
+
// Refresh conversation list to update active state (single call)
|
| 172 |
conversationsUI.refresh();
|
| 173 |
|
| 174 |
// Show notification when switching conversations (but not on initial load)
|
|
|
|
| 211 |
updateModelSelection();
|
| 212 |
updateReasoningSection();
|
| 213 |
checkApiKey();
|
| 214 |
+
restoreFocusMode(); // Restore focus mode preference
|
| 215 |
conversationsUI.init();
|
| 216 |
await initializeConversations();
|
| 217 |
}
|
|
|
|
| 457 |
sendMessage();
|
| 458 |
}
|
| 459 |
}
|
| 460 |
+
// Ctrl/Cmd + B: Toggle focus mode
|
| 461 |
+
if ((e.ctrlKey || e.metaKey) && e.key === "b") {
|
| 462 |
+
e.preventDefault();
|
| 463 |
+
toggleFocusMode();
|
| 464 |
+
}
|
| 465 |
+
});
|
| 466 |
+
|
| 467 |
+
// Focus mode button
|
| 468 |
+
if (elements.focusModeBtn) {
|
| 469 |
+
elements.focusModeBtn.addEventListener("click", toggleFocusMode);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// Scroll to bottom button
|
| 473 |
+
if (elements.scrollToBottomBtn) {
|
| 474 |
+
elements.scrollToBottomBtn.addEventListener("click", scrollToBottom);
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
// Show/hide scroll-to-bottom button based on scroll position
|
| 478 |
+
elements.chatMessages.addEventListener("scroll", handleChatScroll);
|
| 479 |
+
|
| 480 |
+
// Warn user before closing tab if streaming is in progress
|
| 481 |
+
window.addEventListener("beforeunload", e => {
|
| 482 |
+
if (state.isStreaming) {
|
| 483 |
+
e.preventDefault();
|
| 484 |
+
e.returnValue = "A response is currently being generated. Are you sure you want to leave?";
|
| 485 |
+
return e.returnValue;
|
| 486 |
+
}
|
| 487 |
});
|
| 488 |
}
|
| 489 |
|
| 490 |
+
// Debounce helper for performance
|
| 491 |
+
let resizeTimeout = null;
|
| 492 |
+
|
| 493 |
// Handle input change
|
| 494 |
function handleInputChange() {
|
| 495 |
+
// Debounced auto-resize textarea
|
| 496 |
+
if (resizeTimeout) clearTimeout(resizeTimeout);
|
| 497 |
+
resizeTimeout = setTimeout(() => {
|
| 498 |
+
elements.messageInput.style.height = "auto";
|
| 499 |
+
elements.messageInput.style.height = elements.messageInput.scrollHeight + "px";
|
| 500 |
+
}, 16); // ~60fps
|
| 501 |
|
| 502 |
updateSendButton();
|
| 503 |
}
|
| 504 |
|
| 505 |
+
// ========================================
|
| 506 |
+
// Scroll to Bottom & Focus Mode
|
| 507 |
+
// ========================================
|
| 508 |
+
|
| 509 |
+
// Scroll to bottom of chat
|
| 510 |
+
function scrollToBottom() {
|
| 511 |
+
elements.chatMessages.scrollTo({
|
| 512 |
+
top: elements.chatMessages.scrollHeight,
|
| 513 |
+
behavior: "smooth"
|
| 514 |
+
});
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
// Handle chat scroll - show/hide scroll-to-bottom button
|
| 518 |
+
let scrollTimeout = null;
|
| 519 |
+
function handleChatScroll() {
|
| 520 |
+
if (scrollTimeout) clearTimeout(scrollTimeout);
|
| 521 |
+
scrollTimeout = setTimeout(() => {
|
| 522 |
+
const { scrollTop, scrollHeight, clientHeight } = elements.chatMessages;
|
| 523 |
+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
| 524 |
+
|
| 525 |
+
// Show button if user has scrolled up more than 200px from bottom
|
| 526 |
+
if (elements.scrollToBottomBtn) {
|
| 527 |
+
if (distanceFromBottom > 200) {
|
| 528 |
+
elements.scrollToBottomBtn.classList.remove("hidden");
|
| 529 |
+
} else {
|
| 530 |
+
elements.scrollToBottomBtn.classList.add("hidden");
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
}, 100);
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
// Toggle focus mode (hide/show sidebar on desktop)
|
| 537 |
+
function toggleFocusMode() {
|
| 538 |
+
document.body.classList.toggle("focus-mode");
|
| 539 |
+
const isFocusMode = document.body.classList.contains("focus-mode");
|
| 540 |
+
|
| 541 |
+
// Save preference
|
| 542 |
+
localStorage.setItem("focusMode", isFocusMode ? "true" : "false");
|
| 543 |
+
|
| 544 |
+
// Show notification
|
| 545 |
+
showNotification(isFocusMode ? "Focus mode enabled (Ctrl+B to exit)" : "Focus mode disabled", "info");
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
// Restore focus mode on load
|
| 549 |
+
function restoreFocusMode() {
|
| 550 |
+
const savedFocusMode = localStorage.getItem("focusMode");
|
| 551 |
+
if (savedFocusMode === "true") {
|
| 552 |
+
document.body.classList.add("focus-mode");
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
// Check API key
|
| 557 |
function checkApiKey() {
|
| 558 |
if (!state.apiKey) {
|
|
|
|
| 883 |
|
| 884 |
// Delete a message from UI, state, and database
|
| 885 |
async function deleteMessage(messageDiv, content, role) {
|
| 886 |
+
// Get the index of this message in the DOM to find correct state index
|
| 887 |
+
const allMessages = Array.from(elements.chatMessages.querySelectorAll(".message"));
|
| 888 |
+
const domIndex = allMessages.indexOf(messageDiv);
|
| 889 |
+
|
| 890 |
+
// Find and remove from state.messages using index (more reliable than content matching)
|
| 891 |
+
if (domIndex !== -1 && domIndex < state.messages.length) {
|
| 892 |
+
state.messages.splice(domIndex, 1);
|
| 893 |
+
} else {
|
| 894 |
+
// Fallback to content matching if index fails
|
| 895 |
+
const msgIndex = state.messages.findIndex(m => m.role === role && m.content === content);
|
| 896 |
+
if (msgIndex !== -1) {
|
| 897 |
+
state.messages.splice(msgIndex, 1);
|
| 898 |
+
}
|
| 899 |
}
|
| 900 |
|
| 901 |
// Remove from database if we have a messageId
|
|
|
|
| 1087 |
);
|
| 1088 |
state.messages.push({ role: "assistant", content: fullContent });
|
| 1089 |
|
| 1090 |
+
// Add cost for this regenerated response
|
| 1091 |
+
if (state.lastUsage) {
|
| 1092 |
+
updateTokenCounter(true);
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
try {
|
| 1096 |
await saveMessageToDB("assistant", fullContent, reasoning);
|
| 1097 |
} catch (error) {}
|
|
|
|
| 1286 |
footerDiv.innerHTML = `
|
| 1287 |
<span class="message-time">${timeStr}</span>
|
| 1288 |
<div class="message-actions">
|
| 1289 |
+
<button class="msg-action-btn" data-action="regenerate" title="Regenerate">
|
| 1290 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1291 |
+
<polyline points="23 4 23 10 17 10"></polyline>
|
| 1292 |
+
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
| 1293 |
+
</svg>
|
| 1294 |
+
</button>
|
| 1295 |
<button class="msg-action-btn" data-action="copy" title="Copy">
|
| 1296 |
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1297 |
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
|
|
| 1307 |
</div>
|
| 1308 |
`;
|
| 1309 |
|
| 1310 |
+
// Regenerate button handler
|
| 1311 |
+
footerDiv.querySelector('[data-action="regenerate"]').addEventListener("click", async () => {
|
| 1312 |
+
await regenerateResponse(messageDiv);
|
| 1313 |
+
});
|
| 1314 |
+
|
| 1315 |
footerDiv.querySelector('[data-action="copy"]').addEventListener("click", () => {
|
| 1316 |
navigator.clipboard
|
| 1317 |
.writeText(content)
|
|
|
|
| 1385 |
if (chunk.reasoning) {
|
| 1386 |
reasoning = chunk.reasoning;
|
| 1387 |
}
|
| 1388 |
+
// Handle usage stats (display only, don't add cost yet)
|
| 1389 |
if (chunk.usage) {
|
| 1390 |
state.lastUsage = chunk.usage;
|
| 1391 |
+
updateTokenCounter(false);
|
| 1392 |
}
|
| 1393 |
});
|
| 1394 |
|
|
|
|
| 1397 |
finalizeStreamingMessage(messageDiv, textDiv, contentDiv, fullContent, reasoning, new Date().toISOString());
|
| 1398 |
state.messages.push({ role: "assistant", content: fullContent });
|
| 1399 |
|
| 1400 |
+
// Now add the cost for this complete response
|
| 1401 |
+
if (state.lastUsage) {
|
| 1402 |
+
updateTokenCounter(true);
|
| 1403 |
+
}
|
| 1404 |
+
|
| 1405 |
// Save to database
|
| 1406 |
try {
|
| 1407 |
await saveMessageToDB("assistant", fullContent, reasoning);
|
|
|
|
| 1470 |
}
|
| 1471 |
|
| 1472 |
// Update token counter display
|
| 1473 |
+
function updateTokenCounter(addCost = false) {
|
| 1474 |
const sessionCostEl = document.getElementById("sessionCostDisplay");
|
| 1475 |
const tokenUsageEl = document.getElementById("tokenUsageDisplay");
|
| 1476 |
|
| 1477 |
if (state.lastUsage) {
|
| 1478 |
const input = state.lastUsage.prompt_tokens || 0;
|
| 1479 |
const output = state.lastUsage.completion_tokens || 0;
|
| 1480 |
+
|
| 1481 |
+
// Only add cost when explicitly requested (after a complete response)
|
| 1482 |
+
if (addCost) {
|
| 1483 |
+
const lastCost = calculateCost(state.lastUsage, state.selectedModel);
|
| 1484 |
+
state.totalCost += lastCost;
|
| 1485 |
+
}
|
| 1486 |
|
| 1487 |
const costStr =
|
| 1488 |
state.totalCost < 0.01 ? `${(state.totalCost * 100).toFixed(2)}¢` : `$${state.totalCost.toFixed(4)}`;
|
assets/js/utils.js
CHANGED
|
@@ -147,9 +147,25 @@ function showImportFilePicker() {
|
|
| 147 |
const text = await file.text();
|
| 148 |
const data = JSON.parse(text);
|
| 149 |
|
| 150 |
-
// Validate structure
|
| 151 |
if (!data.conversation || !data.messages || !Array.isArray(data.messages)) {
|
| 152 |
-
throw new Error("Invalid file format");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
}
|
| 154 |
|
| 155 |
resolve(data);
|
|
@@ -166,6 +182,7 @@ function showImportFilePicker() {
|
|
| 166 |
// Export for use in other scripts
|
| 167 |
if (typeof window !== "undefined") {
|
| 168 |
window.escapeHtml = escapeHtml;
|
|
|
|
| 169 |
window.downloadFile = downloadFile;
|
| 170 |
window.sanitizeFilename = sanitizeFilename;
|
| 171 |
window.showExportFormatModal = showExportFormatModal;
|
|
|
|
| 147 |
const text = await file.text();
|
| 148 |
const data = JSON.parse(text);
|
| 149 |
|
| 150 |
+
// Validate basic structure
|
| 151 |
if (!data.conversation || !data.messages || !Array.isArray(data.messages)) {
|
| 152 |
+
throw new Error("Invalid file format: missing conversation or messages");
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// Validate conversation object
|
| 156 |
+
if (!data.conversation.title || typeof data.conversation.title !== "string") {
|
| 157 |
+
throw new Error("Invalid file format: missing or invalid conversation title");
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Validate messages structure
|
| 161 |
+
for (let i = 0; i < data.messages.length; i++) {
|
| 162 |
+
const msg = data.messages[i];
|
| 163 |
+
if (!msg.role || !msg.content) {
|
| 164 |
+
throw new Error(`Invalid message at index ${i}: missing role or content`);
|
| 165 |
+
}
|
| 166 |
+
if (msg.role !== "user" && msg.role !== "assistant") {
|
| 167 |
+
throw new Error(`Invalid message role at index ${i}: must be 'user' or 'assistant'`);
|
| 168 |
+
}
|
| 169 |
}
|
| 170 |
|
| 171 |
resolve(data);
|
|
|
|
| 182 |
// Export for use in other scripts
|
| 183 |
if (typeof window !== "undefined") {
|
| 184 |
window.escapeHtml = escapeHtml;
|
| 185 |
+
window.wrapTablesForScroll = wrapTablesForScroll;
|
| 186 |
window.downloadFile = downloadFile;
|
| 187 |
window.sanitizeFilename = sanitizeFilename;
|
| 188 |
window.showExportFormatModal = showExportFormatModal;
|
index.html
CHANGED
|
@@ -63,17 +63,18 @@
|
|
| 63 |
|
| 64 |
<!-- New Chat Button -->
|
| 65 |
<div class="section">
|
| 66 |
-
<button id="newChatBtn" class="btn-primary w-full">
|
| 67 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 68 |
-
stroke-width="2">
|
| 69 |
<line x1="12" y1="5" x2="12" y2="19"></line>
|
| 70 |
<line x1="5" y1="12" x2="19" y2="12"></line>
|
| 71 |
</svg>
|
| 72 |
<span>New Chat</span>
|
| 73 |
</button>
|
| 74 |
-
<button id="importConversationBtn" class="btn-secondary w-full" style="margin-top: 8px;"
|
|
|
|
| 75 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 76 |
-
stroke-width="2">
|
| 77 |
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
| 78 |
<polyline points="17 8 12 3 7 8"></polyline>
|
| 79 |
<line x1="12" y1="3" x2="12" y2="15"></line>
|
|
@@ -251,14 +252,34 @@
|
|
| 251 |
<!-- Main Content -->
|
| 252 |
<main class="main-content">
|
| 253 |
<!-- Toggle Sidebar Button (Mobile) -->
|
| 254 |
-
<button id="toggleSidebar" class="toggle-sidebar-btn">
|
| 255 |
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
|
|
| 256 |
<line x1="3" y1="12" x2="21" y2="12"></line>
|
| 257 |
<line x1="3" y1="6" x2="21" y2="6"></line>
|
| 258 |
<line x1="3" y1="18" x2="21" y2="18"></line>
|
| 259 |
</svg>
|
| 260 |
</button>
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
<!-- Chat Container -->
|
| 263 |
<div class="chat-container">
|
| 264 |
<div id="chatMessages" class="chat-messages">
|
|
@@ -269,14 +290,22 @@
|
|
| 269 |
</div>
|
| 270 |
</div>
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
<!-- Input Area -->
|
| 273 |
<div class="input-container">
|
| 274 |
<div class="input-wrapper">
|
| 275 |
-
<textarea id="messageInput" class="message-input" placeholder="Type your message..."
|
| 276 |
-
|
| 277 |
-
<button id="sendBtn" class="send-btn">
|
| 278 |
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 279 |
-
stroke-width="2">
|
| 280 |
<line x1="22" y1="2" x2="11" y2="13"></line>
|
| 281 |
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
| 282 |
</svg>
|
|
|
|
| 63 |
|
| 64 |
<!-- New Chat Button -->
|
| 65 |
<div class="section">
|
| 66 |
+
<button id="newChatBtn" class="btn-primary w-full" aria-label="Start a new chat">
|
| 67 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 68 |
+
stroke-width="2" aria-hidden="true">
|
| 69 |
<line x1="12" y1="5" x2="12" y2="19"></line>
|
| 70 |
<line x1="5" y1="12" x2="19" y2="12"></line>
|
| 71 |
</svg>
|
| 72 |
<span>New Chat</span>
|
| 73 |
</button>
|
| 74 |
+
<button id="importConversationBtn" class="btn-secondary w-full" style="margin-top: 8px;"
|
| 75 |
+
aria-label="Import a chat from JSON file">
|
| 76 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 77 |
+
stroke-width="2" aria-hidden="true">
|
| 78 |
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
| 79 |
<polyline points="17 8 12 3 7 8"></polyline>
|
| 80 |
<line x1="12" y1="3" x2="12" y2="15"></line>
|
|
|
|
| 252 |
<!-- Main Content -->
|
| 253 |
<main class="main-content">
|
| 254 |
<!-- Toggle Sidebar Button (Mobile) -->
|
| 255 |
+
<button id="toggleSidebar" class="toggle-sidebar-btn" aria-label="Toggle sidebar">
|
| 256 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
| 257 |
+
aria-hidden="true">
|
| 258 |
<line x1="3" y1="12" x2="21" y2="12"></line>
|
| 259 |
<line x1="3" y1="6" x2="21" y2="6"></line>
|
| 260 |
<line x1="3" y1="18" x2="21" y2="18"></line>
|
| 261 |
</svg>
|
| 262 |
</button>
|
| 263 |
|
| 264 |
+
<!-- Focus Mode Toggle (Desktop) -->
|
| 265 |
+
<button id="focusModeBtn" class="focus-mode-btn" aria-label="Toggle focus mode"
|
| 266 |
+
title="Toggle Focus Mode (Ctrl+B)">
|
| 267 |
+
<svg class="icon-expand" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 268 |
+
stroke-width="2" aria-hidden="true">
|
| 269 |
+
<polyline points="15 3 21 3 21 9"></polyline>
|
| 270 |
+
<polyline points="9 21 3 21 3 15"></polyline>
|
| 271 |
+
<line x1="21" y1="3" x2="14" y2="10"></line>
|
| 272 |
+
<line x1="3" y1="21" x2="10" y2="14"></line>
|
| 273 |
+
</svg>
|
| 274 |
+
<svg class="icon-collapse" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 275 |
+
stroke-width="2" aria-hidden="true">
|
| 276 |
+
<polyline points="4 14 10 14 10 20"></polyline>
|
| 277 |
+
<polyline points="20 10 14 10 14 4"></polyline>
|
| 278 |
+
<line x1="14" y1="10" x2="21" y2="3"></line>
|
| 279 |
+
<line x1="3" y1="21" x2="10" y2="14"></line>
|
| 280 |
+
</svg>
|
| 281 |
+
</button>
|
| 282 |
+
|
| 283 |
<!-- Chat Container -->
|
| 284 |
<div class="chat-container">
|
| 285 |
<div id="chatMessages" class="chat-messages">
|
|
|
|
| 290 |
</div>
|
| 291 |
</div>
|
| 292 |
|
| 293 |
+
<!-- Scroll to Bottom Button -->
|
| 294 |
+
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn hidden" aria-label="Scroll to bottom">
|
| 295 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
| 296 |
+
aria-hidden="true">
|
| 297 |
+
<polyline points="6 9 12 15 18 9"></polyline>
|
| 298 |
+
</svg>
|
| 299 |
+
</button>
|
| 300 |
+
|
| 301 |
<!-- Input Area -->
|
| 302 |
<div class="input-container">
|
| 303 |
<div class="input-wrapper">
|
| 304 |
+
<textarea id="messageInput" class="message-input" placeholder="Type your message..." rows="2"
|
| 305 |
+
aria-label="Message input"></textarea>
|
| 306 |
+
<button id="sendBtn" class="send-btn" aria-label="Send message">
|
| 307 |
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 308 |
+
stroke-width="2" aria-hidden="true">
|
| 309 |
<line x1="22" y1="2" x2="11" y2="13"></line>
|
| 310 |
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
| 311 |
</svg>
|