@@ -7,17 +7,6 @@
|
|||||||
# Also used for the lite (unmanaged) deployment.
|
# Also used for the lite (unmanaged) deployment.
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
# Build stage
|
|
||||||
FROM alpine:3.19 AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apk add --no-cache bash coreutils
|
|
||||||
|
|
||||||
COPY build.sh ./
|
|
||||||
COPY src ./src
|
|
||||||
|
|
||||||
RUN chmod +x build.sh && ./build.sh
|
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
@@ -25,7 +14,7 @@ FROM nginx:alpine
|
|||||||
COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.frontend.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Built SPA files
|
# Built SPA files
|
||||||
COPY --from=builder /app/standalone /usr/share/nginx/html
|
COPY src /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,16 @@ func main() {
|
|||||||
auth := handlers.NewAuthHandler(cfg)
|
auth := handlers.NewAuthHandler(cfg)
|
||||||
authLimiter := middleware.NewRateLimiter(1, 5)
|
authLimiter := middleware.NewRateLimiter(1, 5)
|
||||||
|
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
|
api.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"version": Version,
|
||||||
|
"database": database.IsConnected(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
authGroup := api.Group("/auth")
|
authGroup := api.Group("/auth")
|
||||||
authGroup.Use(authLimiter.Limit())
|
authGroup.Use(authLimiter.Limit())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -781,3 +781,72 @@ body {
|
|||||||
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
|
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ── Connection Status ──────────────────── */
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.connection-status:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.managed .status-dot { background: #22c55e; }
|
||||||
|
.connection-status.managed { color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.3); }
|
||||||
|
|
||||||
|
.connection-status.available .status-dot { background: #f59e0b; }
|
||||||
|
.connection-status.available { color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.3); }
|
||||||
|
|
||||||
|
.connection-status.offline .status-dot { background: var(--text-secondary); }
|
||||||
|
.connection-status.offline { color: var(--text-secondary); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* ── Auth Modal ─────────────────────────── */
|
||||||
|
.auth-modal {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab:hover { color: var(--text-primary); }
|
||||||
|
.auth-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-height: 1.2rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>🔀 Chat Switchboard</h1>
|
<h1>🔀 Chat Switchboard</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<div class="connection-status offline" id="connectionStatus" title="Unmanaged mode">
|
||||||
|
<span class="status-dot"></span> Offline
|
||||||
|
</div>
|
||||||
<button class="btn btn-secondary" id="toggleSidebarBtn" title="Toggle Sidebar (Ctrl+B)">
|
<button class="btn btn-secondary" id="toggleSidebarBtn" title="Toggle Sidebar (Ctrl+B)">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
@@ -195,9 +198,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth Modal -->
|
||||||
|
<div class="modal-overlay" id="authModal">
|
||||||
|
<div class="modal auth-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>🔀 Chat Switchboard</h2>
|
||||||
|
<button class="modal-close" id="authCloseBtn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="auth-tabs">
|
||||||
|
<button class="auth-tab active" id="authTabLogin">Sign In</button>
|
||||||
|
<button class="auth-tab" id="authTabRegister">Register</button>
|
||||||
|
</div>
|
||||||
|
<div id="authLoginForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authLogin">Username or Email</label>
|
||||||
|
<input type="text" id="authLogin" placeholder="jeff or jeff@example.com" autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authPassword">Password</label>
|
||||||
|
<input type="password" id="authPassword" placeholder="••••••••" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="authRegisterForm" style="display:none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authUsername">Username</label>
|
||||||
|
<input type="text" id="authUsername" placeholder="Choose a username" autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authEmail">Email</label>
|
||||||
|
<input type="email" id="authEmail" placeholder="you@example.com" autocomplete="email">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authRegPassword">Password</label>
|
||||||
|
<input type="password" id="authRegPassword" placeholder="At least 8 characters" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="auth-error" id="authError"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="authSkipBtn">Use Offline</button>
|
||||||
|
<button class="btn btn-primary" id="authLoginBtn">Sign In</button>
|
||||||
|
<button class="btn btn-primary" id="authRegisterBtn" style="display:none;">Create Account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast-container" id="toastContainer"></div>
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
<script src="js/storage.js"></script>
|
<script src="js/storage.js"></script>
|
||||||
|
<script src="js/backend.js"></script>
|
||||||
<script src="js/state.js"></script>
|
<script src="js/state.js"></script>
|
||||||
<script src="js/api.js"></script>
|
<script src="js/api.js"></script>
|
||||||
<script src="js/ui.js"></script>
|
<script src="js/ui.js"></script>
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ async function sendApiRequest(messages) {
|
|||||||
});
|
});
|
||||||
updateChat(State.currentChatId, { messages, model });
|
updateChat(State.currentChatId, { messages, model });
|
||||||
|
|
||||||
|
// Persist assistant response to backend (managed mode)
|
||||||
|
await onAssistantResponse(assistantContent, model);
|
||||||
|
|
||||||
if (!State.settings.stream) {
|
if (!State.settings.stream) {
|
||||||
renderMessages(messages);
|
renderMessages(messages);
|
||||||
}
|
}
|
||||||
|
|||||||
305
src/js/app.js
305
src/js/app.js
@@ -2,17 +2,44 @@
|
|||||||
// Chat Switchboard - Main Application
|
// Chat Switchboard - Main Application
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
function init() {
|
async function init() {
|
||||||
console.log('🔀 Chat Switchboard initializing...');
|
console.log('🔀 Chat Switchboard initializing...');
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
loadChats();
|
|
||||||
loadModels();
|
loadModels();
|
||||||
|
Backend.init();
|
||||||
|
|
||||||
|
// Auto-detect backend at same origin
|
||||||
|
if (!Backend.baseUrl) {
|
||||||
|
const health = await Backend.checkHealth('');
|
||||||
|
if (health && health.database) {
|
||||||
|
Backend.baseUrl = window.location.origin; // same origin
|
||||||
|
Backend.save();
|
||||||
|
console.log('🔗 Backend detected at same origin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have saved auth, validate it
|
||||||
|
if (Backend.accessToken) {
|
||||||
|
const health = await Backend.checkHealth(Backend.baseUrl);
|
||||||
|
if (!health) {
|
||||||
|
console.log('⚠️ Backend unreachable, falling back to unmanaged');
|
||||||
|
Backend.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadChats();
|
||||||
initializeEventListeners();
|
initializeEventListeners();
|
||||||
updateQuickModelSelector();
|
updateQuickModelSelector();
|
||||||
renderChatHistory();
|
renderChatHistory();
|
||||||
|
updateConnectionStatus();
|
||||||
console.log('✅ Chat Switchboard ready');
|
|
||||||
|
// If backend available but not logged in, show auth
|
||||||
|
if (Backend.baseUrl && !Backend.accessToken) {
|
||||||
|
showAuthModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Chat Switchboard ready (${Backend.isManaged ? 'managed' : 'unmanaged'} mode)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeEventListeners() {
|
function initializeEventListeners() {
|
||||||
@@ -23,7 +50,7 @@ function initializeEventListeners() {
|
|||||||
document.getElementById('cancelBtn').addEventListener('click', closeSettings);
|
document.getElementById('cancelBtn').addEventListener('click', closeSettings);
|
||||||
document.getElementById('saveBtn').addEventListener('click', handleSaveSettings);
|
document.getElementById('saveBtn').addEventListener('click', handleSaveSettings);
|
||||||
document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true));
|
document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true));
|
||||||
|
|
||||||
// Chat controls
|
// Chat controls
|
||||||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||||
@@ -31,7 +58,7 @@ function initializeEventListeners() {
|
|||||||
document.getElementById('regenerateBtn').addEventListener('click', regenerateResponse);
|
document.getElementById('regenerateBtn').addEventListener('click', regenerateResponse);
|
||||||
document.getElementById('toggleSidebarBtn').addEventListener('click', toggleSidebar);
|
document.getElementById('toggleSidebarBtn').addEventListener('click', toggleSidebar);
|
||||||
document.getElementById('quickFetchBtn').addEventListener('click', () => fetchModels(false));
|
document.getElementById('quickFetchBtn').addEventListener('click', () => fetchModels(false));
|
||||||
|
|
||||||
// Quick model selector
|
// Quick model selector
|
||||||
document.getElementById('quickModel').addEventListener('change', function() {
|
document.getElementById('quickModel').addEventListener('change', function() {
|
||||||
if (this.value) {
|
if (this.value) {
|
||||||
@@ -39,17 +66,17 @@ function initializeEventListeners() {
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export dropdown
|
// Export dropdown
|
||||||
document.getElementById('exportBtn').addEventListener('click', function(e) {
|
document.getElementById('exportBtn').addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
document.getElementById('exportDropdown').classList.toggle('show');
|
document.getElementById('exportDropdown').classList.toggle('show');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('click', function() {
|
document.addEventListener('click', function() {
|
||||||
document.getElementById('exportDropdown').classList.remove('show');
|
document.getElementById('exportDropdown').classList.remove('show');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Message input
|
// Message input
|
||||||
const messageInput = document.getElementById('messageInput');
|
const messageInput = document.getElementById('messageInput');
|
||||||
messageInput.addEventListener('keydown', handleKeyDown);
|
messageInput.addEventListener('keydown', handleKeyDown);
|
||||||
@@ -57,7 +84,7 @@ function initializeEventListeners() {
|
|||||||
this.style.height = 'auto';
|
this.style.height = 'auto';
|
||||||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Settings modal model sync
|
// Settings modal model sync
|
||||||
document.getElementById('model').addEventListener('change', function() {
|
document.getElementById('model').addEventListener('change', function() {
|
||||||
if (this.value) document.getElementById('modelCustom').value = '';
|
if (this.value) document.getElementById('modelCustom').value = '';
|
||||||
@@ -65,12 +92,31 @@ function initializeEventListeners() {
|
|||||||
document.getElementById('modelCustom').addEventListener('input', function() {
|
document.getElementById('modelCustom').addEventListener('input', function() {
|
||||||
if (this.value) document.getElementById('model').value = '';
|
if (this.value) document.getElementById('model').value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal on overlay click
|
// Close modals on overlay click
|
||||||
document.getElementById('settingsModal').addEventListener('click', function(e) {
|
document.getElementById('settingsModal').addEventListener('click', function(e) {
|
||||||
if (e.target === this) closeSettings();
|
if (e.target === this) closeSettings();
|
||||||
});
|
});
|
||||||
|
document.getElementById('authModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
// Only closeable if user has option to skip (unmanaged available)
|
||||||
|
if (!Backend.baseUrl || Backend.accessToken) {
|
||||||
|
closeAuthModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth modal
|
||||||
|
document.getElementById('authLoginBtn').addEventListener('click', handleLogin);
|
||||||
|
document.getElementById('authRegisterBtn').addEventListener('click', handleRegister);
|
||||||
|
document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login'));
|
||||||
|
document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register'));
|
||||||
|
document.getElementById('authCloseBtn').addEventListener('click', closeAuthModal);
|
||||||
|
document.getElementById('authSkipBtn').addEventListener('click', skipAuth);
|
||||||
|
|
||||||
|
// Connection status click
|
||||||
|
document.getElementById('connectionStatus').addEventListener('click', handleConnectionClick);
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
// Global keyboard shortcuts
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.ctrlKey && e.key === ',') {
|
if (e.ctrlKey && e.key === ',') {
|
||||||
@@ -86,6 +132,20 @@ function initializeEventListeners() {
|
|||||||
stopGeneration();
|
stopGeneration();
|
||||||
} else if (document.getElementById('settingsModal').classList.contains('active')) {
|
} else if (document.getElementById('settingsModal').classList.contains('active')) {
|
||||||
closeSettings();
|
closeSettings();
|
||||||
|
} else if (document.getElementById('authModal').classList.contains('active')) {
|
||||||
|
closeAuthModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter key in auth form
|
||||||
|
document.getElementById('authPassword').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const activeTab = document.querySelector('.auth-tab.active');
|
||||||
|
if (activeTab && activeTab.id === 'authTabRegister') {
|
||||||
|
// Focus email field first if registering
|
||||||
|
} else {
|
||||||
|
handleLogin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -101,11 +161,11 @@ function handleKeyDown(e) {
|
|||||||
function handleSaveSettings() {
|
function handleSaveSettings() {
|
||||||
State.settings.apiEndpoint = document.getElementById('apiEndpoint').value.trim();
|
State.settings.apiEndpoint = document.getElementById('apiEndpoint').value.trim();
|
||||||
State.settings.apiKey = document.getElementById('apiKey').value.trim();
|
State.settings.apiKey = document.getElementById('apiKey').value.trim();
|
||||||
|
|
||||||
const modelCustom = document.getElementById('modelCustom').value.trim();
|
const modelCustom = document.getElementById('modelCustom').value.trim();
|
||||||
const modelSelect = document.getElementById('model').value;
|
const modelSelect = document.getElementById('model').value;
|
||||||
State.settings.model = modelCustom || modelSelect || 'gpt-3.5-turbo';
|
State.settings.model = modelCustom || modelSelect || 'gpt-3.5-turbo';
|
||||||
|
|
||||||
State.settings.stream = document.getElementById('streamResponse').checked;
|
State.settings.stream = document.getElementById('streamResponse').checked;
|
||||||
State.settings.saveHistory = document.getElementById('saveHistory').checked;
|
State.settings.saveHistory = document.getElementById('saveHistory').checked;
|
||||||
State.settings.showThinking = document.getElementById('showThinking').checked;
|
State.settings.showThinking = document.getElementById('showThinking').checked;
|
||||||
@@ -114,7 +174,7 @@ function handleSaveSettings() {
|
|||||||
State.settings.temperature = parseFloat(document.getElementById('temperature').value) || 0.7;
|
State.settings.temperature = parseFloat(document.getElementById('temperature').value) || 0.7;
|
||||||
State.settings.topP = parseFloat(document.getElementById('topP').value) || 1;
|
State.settings.topP = parseFloat(document.getElementById('topP').value) || 1;
|
||||||
State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0;
|
State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0;
|
||||||
|
|
||||||
saveSettings();
|
saveSettings();
|
||||||
updateQuickModelSelector();
|
updateQuickModelSelector();
|
||||||
closeSettings();
|
closeSettings();
|
||||||
@@ -139,24 +199,30 @@ function newChat() {
|
|||||||
document.getElementById('messageInput').focus();
|
document.getElementById('messageInput').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadChat(chatId) {
|
async function loadChat(chatId) {
|
||||||
const chat = getChat(chatId);
|
const chat = getChat(chatId);
|
||||||
if (chat) {
|
if (!chat) return;
|
||||||
State.currentChatId = chatId;
|
|
||||||
if (chat.model) {
|
State.currentChatId = chatId;
|
||||||
State.settings.model = chat.model;
|
|
||||||
updateQuickModelSelector();
|
// In managed mode, fetch messages if not cached
|
||||||
}
|
if (Backend.isManaged && chat.messages.length === 0 && chat.messageCount > 0) {
|
||||||
renderMessages(chat.messages);
|
await loadMessages(chatId);
|
||||||
renderChatHistory();
|
|
||||||
updateRegenerateButton(chat.messages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chat.model) {
|
||||||
|
State.settings.model = chat.model;
|
||||||
|
updateQuickModelSelector();
|
||||||
|
}
|
||||||
|
renderMessages(chat.messages);
|
||||||
|
renderChatHistory();
|
||||||
|
updateRegenerateButton(chat.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteChat(chatId, event) {
|
async function handleDeleteChat(chatId, event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (confirm('Delete this chat?')) {
|
if (confirm('Delete this chat?')) {
|
||||||
deleteChat(chatId);
|
await deleteChat(chatId);
|
||||||
if (State.currentChatId === chatId) {
|
if (State.currentChatId === chatId) {
|
||||||
newChat();
|
newChat();
|
||||||
}
|
}
|
||||||
@@ -168,9 +234,9 @@ function handleDeleteChat(chatId, event) {
|
|||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const input = document.getElementById('messageInput');
|
const input = document.getElementById('messageInput');
|
||||||
const message = input.value.trim();
|
const message = input.value.trim();
|
||||||
|
|
||||||
if (!message || State.isGenerating) return;
|
if (!message || State.isGenerating) return;
|
||||||
|
|
||||||
if (!State.settings.apiEndpoint || !State.settings.apiKey) {
|
if (!State.settings.apiEndpoint || !State.settings.apiKey) {
|
||||||
showToast('⚠️ Configure API settings first', 'warning');
|
showToast('⚠️ Configure API settings first', 'warning');
|
||||||
openSettings();
|
openSettings();
|
||||||
@@ -179,56 +245,199 @@ async function sendMessage() {
|
|||||||
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
input.style.height = 'auto';
|
input.style.height = 'auto';
|
||||||
|
|
||||||
let messages = [];
|
let messages = [];
|
||||||
|
|
||||||
if (State.settings.systemPrompt) {
|
if (State.settings.systemPrompt) {
|
||||||
messages.push({ role: 'system', content: State.settings.systemPrompt });
|
messages.push({ role: 'system', content: State.settings.systemPrompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (State.currentChatId) {
|
if (State.currentChatId) {
|
||||||
const chat = getChat(State.currentChatId);
|
const chat = getChat(State.currentChatId);
|
||||||
if (chat) messages = [...chat.messages];
|
if (chat) messages = [...chat.messages];
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: message,
|
content: message,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!State.currentChatId) {
|
if (!State.currentChatId) {
|
||||||
const title = message.substring(0, 50) + (message.length > 50 ? '...' : '');
|
const title = message.substring(0, 50) + (message.length > 50 ? '...' : '');
|
||||||
const chat = createChat(title, messages);
|
const chat = await createChat(title, messages);
|
||||||
|
if (!chat) return;
|
||||||
State.currentChatId = chat.id;
|
State.currentChatId = chat.id;
|
||||||
} else {
|
} else {
|
||||||
updateChat(State.currentChatId, { messages });
|
await updateChat(State.currentChatId, { messages });
|
||||||
|
// Persist user message to backend
|
||||||
|
await persistMessage(State.currentChatId, 'user', message);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMessages(messages);
|
renderMessages(messages);
|
||||||
renderChatHistory();
|
renderChatHistory();
|
||||||
|
|
||||||
await sendApiRequest(messages);
|
await sendApiRequest(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function regenerateResponse() {
|
async function regenerateResponse() {
|
||||||
if (State.isGenerating || !State.currentChatId) return;
|
if (State.isGenerating || !State.currentChatId) return;
|
||||||
|
|
||||||
const chat = getChat(State.currentChatId);
|
const chat = getChat(State.currentChatId);
|
||||||
if (!chat || chat.messages.length === 0) return;
|
if (!chat || chat.messages.length === 0) return;
|
||||||
|
|
||||||
// Remove last assistant message(s)
|
// Remove last assistant message(s)
|
||||||
while (chat.messages.length > 0 && chat.messages[chat.messages.length - 1].role === 'assistant') {
|
while (chat.messages.length > 0 && chat.messages[chat.messages.length - 1].role === 'assistant') {
|
||||||
chat.messages.pop();
|
chat.messages.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chat.messages.length === 0) return;
|
if (chat.messages.length === 0) return;
|
||||||
|
|
||||||
updateChat(State.currentChatId, { messages: chat.messages });
|
await updateChat(State.currentChatId, { messages: chat.messages });
|
||||||
renderMessages(chat.messages);
|
renderMessages(chat.messages);
|
||||||
|
|
||||||
await sendApiRequest(chat.messages);
|
await sendApiRequest(chat.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auth Flow ───────────────────────────────
|
||||||
|
|
||||||
|
function showAuthModal() {
|
||||||
|
document.getElementById('authModal').classList.add('active');
|
||||||
|
document.getElementById('authLogin').value = '';
|
||||||
|
document.getElementById('authPassword').value = '';
|
||||||
|
document.getElementById('authUsername').value = '';
|
||||||
|
document.getElementById('authEmail').value = '';
|
||||||
|
document.getElementById('authError').textContent = '';
|
||||||
|
switchAuthTab('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAuthModal() {
|
||||||
|
document.getElementById('authModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchAuthTab(tab) {
|
||||||
|
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
|
||||||
|
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
|
||||||
|
document.getElementById('authLoginForm').style.display = tab === 'login' ? 'block' : 'none';
|
||||||
|
document.getElementById('authRegisterForm').style.display = tab === 'register' ? 'block' : 'none';
|
||||||
|
document.getElementById('authLoginBtn').style.display = tab === 'login' ? 'block' : 'none';
|
||||||
|
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? 'block' : 'none';
|
||||||
|
document.getElementById('authError').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
const login = document.getElementById('authLogin').value.trim();
|
||||||
|
const password = document.getElementById('authPassword').value;
|
||||||
|
|
||||||
|
if (!login || !password) {
|
||||||
|
document.getElementById('authError').textContent = 'Please fill in all fields';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthLoading(true);
|
||||||
|
try {
|
||||||
|
await Backend.login(login, password);
|
||||||
|
closeAuthModal();
|
||||||
|
await loadChats();
|
||||||
|
renderChatHistory();
|
||||||
|
updateConnectionStatus();
|
||||||
|
showToast(`✅ Welcome back, ${Backend.user.username}`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('authError').textContent = e.message;
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
const username = document.getElementById('authUsername').value.trim();
|
||||||
|
const email = document.getElementById('authEmail').value.trim();
|
||||||
|
const password = document.getElementById('authRegPassword').value;
|
||||||
|
|
||||||
|
if (!username || !email || !password) {
|
||||||
|
document.getElementById('authError').textContent = 'Please fill in all fields';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
document.getElementById('authError').textContent = 'Password must be at least 8 characters';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthLoading(true);
|
||||||
|
try {
|
||||||
|
await Backend.register(username, email, password);
|
||||||
|
closeAuthModal();
|
||||||
|
await loadChats();
|
||||||
|
renderChatHistory();
|
||||||
|
updateConnectionStatus();
|
||||||
|
showToast(`✅ Welcome, ${Backend.user.username}!`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('authError').textContent = e.message;
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAuthLoading(loading) {
|
||||||
|
const btns = document.querySelectorAll('#authLoginBtn, #authRegisterBtn');
|
||||||
|
btns.forEach(btn => {
|
||||||
|
btn.disabled = loading;
|
||||||
|
if (loading) btn.dataset.origText = btn.textContent;
|
||||||
|
btn.textContent = loading ? '⏳ Please wait...' : (btn.dataset.origText || btn.textContent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipAuth() {
|
||||||
|
closeAuthModal();
|
||||||
|
Backend.clear();
|
||||||
|
updateConnectionStatus();
|
||||||
|
showToast('📴 Running in offline mode', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection Status ───────────────────────
|
||||||
|
|
||||||
|
function updateConnectionStatus() {
|
||||||
|
const el = document.getElementById('connectionStatus');
|
||||||
|
if (Backend.isManaged) {
|
||||||
|
el.className = 'connection-status managed';
|
||||||
|
el.innerHTML = `<span class="status-dot"></span> ${Backend.user?.username || 'Connected'}`;
|
||||||
|
el.title = 'Managed mode – click to sign out';
|
||||||
|
} else if (Backend.baseUrl) {
|
||||||
|
el.className = 'connection-status available';
|
||||||
|
el.innerHTML = '<span class="status-dot"></span> Sign in';
|
||||||
|
el.title = 'Backend available – click to sign in';
|
||||||
|
} else {
|
||||||
|
el.className = 'connection-status offline';
|
||||||
|
el.innerHTML = '<span class="status-dot"></span> Offline';
|
||||||
|
el.title = 'Unmanaged mode – data stored locally';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnectionClick() {
|
||||||
|
if (Backend.isManaged) {
|
||||||
|
if (confirm('Sign out? Your chats are saved on the server.')) {
|
||||||
|
await Backend.logout();
|
||||||
|
State.chats = [];
|
||||||
|
State.currentChatId = null;
|
||||||
|
loadChats();
|
||||||
|
newChat();
|
||||||
|
renderChatHistory();
|
||||||
|
updateConnectionStatus();
|
||||||
|
showToast('👋 Signed out', 'success');
|
||||||
|
}
|
||||||
|
} else if (Backend.baseUrl) {
|
||||||
|
showAuthModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Post-LLM Persistence ────────────────────
|
||||||
|
// Called by api.js after assistant response is complete.
|
||||||
|
|
||||||
|
async function onAssistantResponse(content, model) {
|
||||||
|
if (Backend.isManaged && State.currentChatId) {
|
||||||
|
await persistMessage(State.currentChatId, 'assistant', content, model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize on DOM ready
|
// Initialize on DOM ready
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
229
src/js/backend.js
Normal file
229
src/js/backend.js
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Switchboard Backend API Client
|
||||||
|
// ==========================================
|
||||||
|
// Handles auth + chat/message CRUD against the
|
||||||
|
// Switchboard backend. Token refresh is automatic.
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const Backend = {
|
||||||
|
baseUrl: '',
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
user: null,
|
||||||
|
_refreshPromise: null,
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
return !!(this.baseUrl && this.accessToken);
|
||||||
|
},
|
||||||
|
|
||||||
|
get isManaged() {
|
||||||
|
return this.isConnected;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Init ────────────────────────────────
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Auto-detect: if we're served from a domain with /api/v1,
|
||||||
|
// the backend is co-located. Otherwise check saved URL.
|
||||||
|
const saved = Storage.get('switchboard_backend');
|
||||||
|
if (saved) {
|
||||||
|
this.baseUrl = saved.baseUrl || '';
|
||||||
|
this.accessToken = saved.accessToken || null;
|
||||||
|
this.refreshToken = saved.refreshToken || null;
|
||||||
|
this.user = saved.user || null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
save() {
|
||||||
|
Storage.set('switchboard_backend', {
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
accessToken: this.accessToken,
|
||||||
|
refreshToken: this.refreshToken,
|
||||||
|
user: this.user
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.accessToken = null;
|
||||||
|
this.refreshToken = null;
|
||||||
|
this.user = null;
|
||||||
|
this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Auth ────────────────────────────────
|
||||||
|
|
||||||
|
async register(username, email, password) {
|
||||||
|
const data = await this._fetch('/api/v1/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, email, password })
|
||||||
|
}, true);
|
||||||
|
this._setAuth(data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(login, password) {
|
||||||
|
const data = await this._fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ login, password })
|
||||||
|
}, true);
|
||||||
|
this._setAuth(data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await this._fetch('/api/v1/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refresh_token: this.refreshToken })
|
||||||
|
}, true);
|
||||||
|
} catch (e) {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
this.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
// Deduplicate concurrent refresh calls
|
||||||
|
if (this._refreshPromise) return this._refreshPromise;
|
||||||
|
|
||||||
|
this._refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await this._fetch('/api/v1/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refresh_token: this.refreshToken })
|
||||||
|
}, true);
|
||||||
|
this._setAuth(data);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
this.clear();
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this._refreshPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this._refreshPromise;
|
||||||
|
},
|
||||||
|
|
||||||
|
_setAuth(data) {
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.refreshToken = data.refresh_token;
|
||||||
|
this.user = data.user;
|
||||||
|
this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Health ───────────────────────────────
|
||||||
|
|
||||||
|
async checkHealth(url) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url + '/api/v1/health', {
|
||||||
|
signal: AbortSignal.timeout(5000)
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Chats ───────────────────────────────
|
||||||
|
|
||||||
|
async listChats(page = 1, perPage = 50) {
|
||||||
|
return this._authedFetch(`/api/v1/chats?page=${page}&per_page=${perPage}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createChat(title, model, systemPrompt) {
|
||||||
|
const body = { title };
|
||||||
|
if (model) body.model = model;
|
||||||
|
if (systemPrompt) body.system_prompt = systemPrompt;
|
||||||
|
return this._authedFetch('/api/v1/chats', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getChat(chatId) {
|
||||||
|
return this._authedFetch(`/api/v1/chats/${chatId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateChat(chatId, updates) {
|
||||||
|
return this._authedFetch(`/api/v1/chats/${chatId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteChat(chatId) {
|
||||||
|
return this._authedFetch(`/api/v1/chats/${chatId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Messages ────────────────────────────
|
||||||
|
|
||||||
|
async listMessages(chatId, page = 1, perPage = 100) {
|
||||||
|
return this._authedFetch(
|
||||||
|
`/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createMessage(chatId, role, content, model) {
|
||||||
|
const body = { role, content };
|
||||||
|
if (model) body.model = model;
|
||||||
|
return this._authedFetch(`/api/v1/chats/${chatId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── HTTP Layer ──────────────────────────
|
||||||
|
|
||||||
|
async _authedFetch(path, opts = {}) {
|
||||||
|
try {
|
||||||
|
return await this._fetch(path, {
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
...opts.headers,
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// If 401, try refresh once
|
||||||
|
if (e.status === 401 && this.refreshToken) {
|
||||||
|
const refreshed = await this.refresh();
|
||||||
|
if (refreshed) {
|
||||||
|
return this._fetch(path, {
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
...opts.headers,
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _fetch(path, opts = {}, skipAuth = false) {
|
||||||
|
const url = this.baseUrl + path;
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...opts.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
const err = new Error(body.error || `HTTP ${resp.status}`);
|
||||||
|
err.status = resp.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
173
src/js/state.js
173
src/js/state.js
@@ -1,5 +1,8 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// State Management
|
// State Management (Mode-Aware)
|
||||||
|
// ==========================================
|
||||||
|
// In unmanaged mode: localStorage (same as before)
|
||||||
|
// In managed mode: backend API with local cache
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
const State = {
|
const State = {
|
||||||
@@ -23,6 +26,8 @@ const State = {
|
|||||||
abortController: null
|
abortController: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Settings (always localStorage) ──────────
|
||||||
|
|
||||||
function loadSettings() {
|
function loadSettings() {
|
||||||
const saved = Storage.get('chatSwitchboard_settings');
|
const saved = Storage.get('chatSwitchboard_settings');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -34,6 +39,8 @@ function saveSettings() {
|
|||||||
Storage.set('chatSwitchboard_settings', State.settings);
|
Storage.set('chatSwitchboard_settings', State.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Models (always localStorage) ────────────
|
||||||
|
|
||||||
function loadModels() {
|
function loadModels() {
|
||||||
State.models = Storage.get('chatSwitchboard_models', []);
|
State.models = Storage.get('chatSwitchboard_models', []);
|
||||||
}
|
}
|
||||||
@@ -42,42 +49,130 @@ function saveModels() {
|
|||||||
Storage.set('chatSwitchboard_models', State.models);
|
Storage.set('chatSwitchboard_models', State.models);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadChats() {
|
// ── Chats ───────────────────────────────────
|
||||||
State.chats = Storage.get('chatSwitchboard_chats', []);
|
|
||||||
|
async function loadChats() {
|
||||||
|
if (Backend.isManaged) {
|
||||||
|
try {
|
||||||
|
const resp = await Backend.listChats(1, 100);
|
||||||
|
State.chats = (resp.data || []).map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
title: c.title,
|
||||||
|
model: c.model || '',
|
||||||
|
systemPrompt: c.system_prompt || '',
|
||||||
|
isArchived: c.is_archived,
|
||||||
|
isPinned: c.is_pinned,
|
||||||
|
messageCount: c.message_count || 0,
|
||||||
|
messages: [], // loaded on demand
|
||||||
|
createdAt: c.created_at,
|
||||||
|
updatedAt: c.updated_at
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load chats from backend:', e);
|
||||||
|
showToast('⚠️ Failed to load chats', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
State.chats = Storage.get('chatSwitchboard_chats', []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveChats() {
|
function saveChats() {
|
||||||
if (State.settings.saveHistory) {
|
// In managed mode, individual operations handle persistence.
|
||||||
|
// In unmanaged mode, write to localStorage.
|
||||||
|
if (!Backend.isManaged && State.settings.saveHistory) {
|
||||||
Storage.set('chatSwitchboard_chats', State.chats);
|
Storage.set('chatSwitchboard_chats', State.chats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChat(title, messages) {
|
async function createChat(title, messages) {
|
||||||
const chat = {
|
if (Backend.isManaged) {
|
||||||
id: Date.now().toString(),
|
try {
|
||||||
title,
|
const resp = await Backend.createChat(
|
||||||
messages,
|
title,
|
||||||
model: State.settings.model,
|
State.settings.model,
|
||||||
createdAt: new Date().toISOString(),
|
State.settings.systemPrompt
|
||||||
updatedAt: new Date().toISOString()
|
);
|
||||||
};
|
const chat = {
|
||||||
State.chats.unshift(chat);
|
id: resp.id,
|
||||||
if (State.chats.length > 100) {
|
title: resp.title,
|
||||||
State.chats = State.chats.slice(0, 100);
|
model: resp.model || '',
|
||||||
|
systemPrompt: resp.system_prompt || '',
|
||||||
|
messages: messages || [],
|
||||||
|
messageCount: 0,
|
||||||
|
createdAt: resp.created_at,
|
||||||
|
updatedAt: resp.updated_at
|
||||||
|
};
|
||||||
|
State.chats.unshift(chat);
|
||||||
|
|
||||||
|
// Persist any initial messages (system prompt, first user msg)
|
||||||
|
for (const msg of (messages || [])) {
|
||||||
|
await Backend.createMessage(chat.id, msg.role, msg.content, msg.model);
|
||||||
|
}
|
||||||
|
chat.messageCount = (messages || []).length;
|
||||||
|
|
||||||
|
return chat;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create chat:', e);
|
||||||
|
showToast('⚠️ Failed to create chat', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const chat = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title,
|
||||||
|
messages: messages || [],
|
||||||
|
model: State.settings.model,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
State.chats.unshift(chat);
|
||||||
|
if (State.chats.length > 100) {
|
||||||
|
State.chats = State.chats.slice(0, 100);
|
||||||
|
}
|
||||||
|
saveChats();
|
||||||
|
return chat;
|
||||||
}
|
}
|
||||||
saveChats();
|
|
||||||
return chat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChat(chatId, updates) {
|
async function updateChat(chatId, updates) {
|
||||||
const chat = State.chats.find(c => c.id === chatId);
|
const chat = State.chats.find(c => c.id === chatId);
|
||||||
if (chat) {
|
if (!chat) return;
|
||||||
|
|
||||||
|
if (Backend.isManaged) {
|
||||||
|
// Separate backend-updateable fields from local-only fields
|
||||||
|
const backendUpdates = {};
|
||||||
|
if (updates.title !== undefined) backendUpdates.title = updates.title;
|
||||||
|
if (updates.model !== undefined) backendUpdates.model = updates.model;
|
||||||
|
if (updates.is_archived !== undefined) backendUpdates.is_archived = updates.is_archived;
|
||||||
|
if (updates.is_pinned !== undefined) backendUpdates.is_pinned = updates.is_pinned;
|
||||||
|
|
||||||
|
if (Object.keys(backendUpdates).length > 0) {
|
||||||
|
try {
|
||||||
|
await Backend.updateChat(chatId, backendUpdates);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update chat:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local cache
|
||||||
|
Object.assign(chat, updates, { updatedAt: new Date().toISOString() });
|
||||||
|
} else {
|
||||||
Object.assign(chat, updates, { updatedAt: new Date().toISOString() });
|
Object.assign(chat, updates, { updatedAt: new Date().toISOString() });
|
||||||
saveChats();
|
saveChats();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteChat(chatId) {
|
async function deleteChat(chatId) {
|
||||||
|
if (Backend.isManaged) {
|
||||||
|
try {
|
||||||
|
await Backend.deleteChat(chatId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete chat:', e);
|
||||||
|
showToast('⚠️ Failed to delete chat', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
State.chats = State.chats.filter(c => c.id !== chatId);
|
State.chats = State.chats.filter(c => c.id !== chatId);
|
||||||
saveChats();
|
saveChats();
|
||||||
}
|
}
|
||||||
@@ -89,3 +184,39 @@ function getChat(chatId) {
|
|||||||
function getCurrentChat() {
|
function getCurrentChat() {
|
||||||
return State.currentChatId ? getChat(State.currentChatId) : null;
|
return State.currentChatId ? getChat(State.currentChatId) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Messages (managed mode) ─────────────────
|
||||||
|
|
||||||
|
async function loadMessages(chatId) {
|
||||||
|
if (!Backend.isManaged) return;
|
||||||
|
|
||||||
|
const chat = getChat(chatId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
// Skip if already loaded
|
||||||
|
if (chat.messages && chat.messages.length > 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await Backend.listMessages(chatId, 1, 200);
|
||||||
|
chat.messages = (resp.data || []).map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model || '',
|
||||||
|
timestamp: m.created_at,
|
||||||
|
_backendId: m.id
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load messages:', e);
|
||||||
|
showToast('⚠️ Failed to load messages', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistMessage(chatId, role, content, model) {
|
||||||
|
if (!Backend.isManaged) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Backend.createMessage(chatId, role, content, model);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to persist message:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user