Changeset 0.8.0 (#39)
This commit is contained in:
302
src/js/app.js
302
src/js/app.js
@@ -242,9 +242,17 @@ async function selectChat(chatId) {
|
||||
|
||||
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||||
try {
|
||||
const resp = await API.listMessages(chatId);
|
||||
chat.messages = (resp.data || []).map(m => ({
|
||||
role: m.role, content: m.content, model: m.model || '', timestamp: m.created_at
|
||||
const resp = await API.getActivePath(chatId);
|
||||
chat.messages = (resp.path || []).map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages:', e.message);
|
||||
@@ -309,7 +317,8 @@ async function sendMessage() {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat) return;
|
||||
|
||||
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
|
||||
// Optimistic: show user message immediately
|
||||
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
||||
UI.renderMessages(chat.messages);
|
||||
|
||||
App.isGenerating = true;
|
||||
@@ -318,14 +327,15 @@ async function sendMessage() {
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
|
||||
const assistantContent = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
chat.messages.push({ role: 'assistant', content: assistantContent, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.renderMessages(chat.messages);
|
||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
|
||||
// Reload active path from server to get proper IDs and sibling metadata
|
||||
await reloadActivePath();
|
||||
UI.showRegenerate(true);
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
UI.toast('Generation stopped', 'warning');
|
||||
await reloadActivePath();
|
||||
} else {
|
||||
console.error('Completion error:', e);
|
||||
const msg = e.message || '';
|
||||
@@ -348,34 +358,63 @@ async function sendMessage() {
|
||||
|
||||
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
|
||||
|
||||
async function regenerate() {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
// ── Reload Active Path from Server ──────────
|
||||
|
||||
async function reloadActivePath() {
|
||||
if (!App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat || chat.messages.length === 0) return;
|
||||
if (!chat) return;
|
||||
|
||||
while (chat.messages.length && chat.messages[chat.messages.length - 1].role === 'assistant') chat.messages.pop();
|
||||
if (chat.messages.length === 0) return;
|
||||
try {
|
||||
const resp = await API.getActivePath(App.currentChatId);
|
||||
chat.messages = (resp.path || []).map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.renderMessages(chat.messages);
|
||||
} catch (e) {
|
||||
console.error('Failed to reload path:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
UI.renderMessages(chat.messages);
|
||||
const lastUser = chat.messages[chat.messages.length - 1];
|
||||
if (lastUser.role !== 'user') return;
|
||||
// ── Regenerate (tree-aware: creates sibling) ─
|
||||
|
||||
async function regenerateMessage(messageId) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.models.find(m => m.id === selectedId);
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId, presetId);
|
||||
const content = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
chat.messages.push({ role: 'assistant', content, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
|
||||
UI.renderMessages(chat.messages);
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, messageId, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
|
||||
// Reload from server to get proper tree state
|
||||
await reloadActivePath();
|
||||
UI.showRegenerate(true);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
if (e.name === 'AbortError') {
|
||||
UI.toast('Generation stopped', 'warning');
|
||||
await reloadActivePath();
|
||||
} else {
|
||||
const msg = e.message || '';
|
||||
if (msg.includes('provider error') && msg.includes('401')) {
|
||||
UI.toast('Provider API key rejected — check Settings', 'error');
|
||||
@@ -388,6 +427,212 @@ async function regenerate() {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: bottom-bar regenerate button targets the last assistant message
|
||||
async function regenerate() {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat || chat.messages.length === 0) return;
|
||||
|
||||
// Find the last assistant message with an ID
|
||||
for (let i = chat.messages.length - 1; i >= 0; i--) {
|
||||
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
|
||||
return regenerateMessage(chat.messages[i].id);
|
||||
}
|
||||
}
|
||||
UI.toast('No assistant message to regenerate', 'warning');
|
||||
}
|
||||
|
||||
// ── Edit Message (creates sibling, triggers completion) ──
|
||||
|
||||
async function editMessage(messageId) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat) return;
|
||||
|
||||
const msg = chat.messages.find(m => m.id === messageId);
|
||||
if (!msg || msg.role !== 'user') return;
|
||||
|
||||
// Show inline edit UI
|
||||
UI.showEditInline(messageId, msg.content);
|
||||
}
|
||||
|
||||
async function submitEdit(messageId, newContent) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (!newContent.trim()) return;
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.models.find(m => m.id === selectedId);
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
|
||||
// Step 1: Create sibling user message via edit endpoint
|
||||
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
|
||||
|
||||
// Step 2: Reload path to show the new edited message
|
||||
await reloadActivePath();
|
||||
|
||||
// Step 3: Generate assistant response as child of the new edit.
|
||||
// Uses regenerate endpoint (which handles user messages by creating
|
||||
// a child response, not a sibling). This avoids the duplicate user
|
||||
// message that streamCompletion would create.
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, editResp.id, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
|
||||
// Step 4: Final reload to get the complete tree state
|
||||
await reloadActivePath();
|
||||
UI.showRegenerate(true);
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
UI.toast('Generation stopped', 'warning');
|
||||
await reloadActivePath();
|
||||
} else {
|
||||
console.error('Edit error:', e);
|
||||
UI.toast(e.message || 'Edit failed', 'error');
|
||||
await reloadActivePath();
|
||||
}
|
||||
} finally {
|
||||
App.isGenerating = false;
|
||||
App.abortController = null;
|
||||
UI.setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
// Re-render to remove the edit form
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (chat) UI.renderMessages(chat.messages);
|
||||
}
|
||||
|
||||
// ── Switch Branch (sibling navigation) ──────
|
||||
|
||||
async function switchSibling(messageId, direction) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
|
||||
try {
|
||||
// Get siblings for this message
|
||||
const data = await API.listSiblings(App.currentChatId, messageId);
|
||||
const siblings = data.siblings || [];
|
||||
const currentIdx = data.current_index ?? 0;
|
||||
|
||||
const targetIdx = currentIdx + direction;
|
||||
if (targetIdx < 0 || targetIdx >= siblings.length) return;
|
||||
|
||||
const targetSibling = siblings[targetIdx];
|
||||
|
||||
// Update cursor to the target sibling (backend walks to leaf)
|
||||
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
|
||||
|
||||
// Update local state with the new path
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (chat && resp.path) {
|
||||
chat.messages = resp.path.map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Branch switch failed:', e.message);
|
||||
UI.toast('Failed to switch branch', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal Helpers ───────────────────────────
|
||||
|
||||
function openModal(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.add('active');
|
||||
// Defer overflow check — browser needs a frame to lay out the modal
|
||||
requestAnimationFrame(() => {
|
||||
el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow);
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
}
|
||||
|
||||
// Toggle .is-scrollable on tab bars that overflow horizontally
|
||||
// and inject arrow navigation buttons when needed.
|
||||
function checkTabsOverflow(tabs) {
|
||||
if (!tabs) return;
|
||||
const scrollable = tabs.scrollWidth > tabs.clientWidth + 1;
|
||||
|
||||
if (!scrollable) {
|
||||
// Remove wrapper if overflow went away (e.g. zoom decreased)
|
||||
const wrap = tabs.parentElement;
|
||||
if (wrap && wrap.classList.contains('modal-tabs-wrap')) {
|
||||
wrap.parentElement.insertBefore(tabs, wrap);
|
||||
wrap.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap if not already wrapped
|
||||
if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'modal-tabs-wrap';
|
||||
tabs.parentElement.insertBefore(wrap, tabs);
|
||||
wrap.appendChild(tabs);
|
||||
|
||||
// Inject arrows
|
||||
const left = document.createElement('button');
|
||||
left.className = 'tab-arrow tab-arrow-left';
|
||||
left.innerHTML = '‹';
|
||||
left.addEventListener('click', () => { tabs.scrollLeft -= 120; });
|
||||
|
||||
const right = document.createElement('button');
|
||||
right.className = 'tab-arrow tab-arrow-right';
|
||||
right.innerHTML = '›';
|
||||
right.addEventListener('click', () => { tabs.scrollLeft += 120; });
|
||||
|
||||
wrap.appendChild(left);
|
||||
wrap.appendChild(right);
|
||||
|
||||
// Update arrow visibility on scroll
|
||||
tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true });
|
||||
}
|
||||
|
||||
updateTabArrows(tabs);
|
||||
}
|
||||
|
||||
function updateTabArrows(tabs) {
|
||||
const wrap = tabs.parentElement;
|
||||
if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return;
|
||||
|
||||
const left = wrap.querySelector('.tab-arrow-left');
|
||||
const right = wrap.querySelector('.tab-arrow-right');
|
||||
if (!left || !right) return;
|
||||
|
||||
const atStart = tabs.scrollLeft <= 2;
|
||||
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2;
|
||||
|
||||
left.classList.toggle('visible', !atStart);
|
||||
right.classList.toggle('visible', !atEnd);
|
||||
}
|
||||
|
||||
// ── Auth Flow ────────────────────────────────
|
||||
|
||||
function showSplash(health) {
|
||||
@@ -511,7 +756,6 @@ function initListeners() {
|
||||
// Chat actions
|
||||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||||
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
|
||||
|
||||
// Model selector (custom dropdown)
|
||||
UI.initModelDropdown();
|
||||
@@ -660,13 +904,23 @@ function initListeners() {
|
||||
// Close modals on overlay click
|
||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.classList.remove('active');
|
||||
if (e.target === overlay) closeModal(overlay.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && App.isGenerating) stopGeneration();
|
||||
if (e.key === 'Escape') {
|
||||
if (App.isGenerating) { stopGeneration(); return; }
|
||||
// Close topmost open modal
|
||||
const open = [...document.querySelectorAll('.modal-overlay.active')];
|
||||
if (open.length) closeModal(open[open.length - 1].id);
|
||||
}
|
||||
});
|
||||
|
||||
// Recheck tab overflow on resize / zoom changes
|
||||
window.addEventListener('resize', () => {
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user