Changeset 0.8.0 (#39)
This commit is contained in:
@@ -428,6 +428,79 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.msg-action-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* Branch navigation indicator */
|
||||
.branch-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.branch-arrow {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
padding: 0 5px;
|
||||
font-size: 16px;
|
||||
line-height: 18px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.branch-arrow:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
|
||||
.branch-arrow:disabled { opacity: 0.25; cursor: default; }
|
||||
.branch-pos {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Inline message editing */
|
||||
.msg-edit-input {
|
||||
width: 100%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font);
|
||||
font-size: var(--msg-font, 14px);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
max-height: 400px;
|
||||
outline: none;
|
||||
}
|
||||
.msg-edit-input:focus { border-color: var(--accent-hover); box-shadow: 0 0 0 2px var(--accent-dim); }
|
||||
.msg-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.msg-edit-cancel, .msg-edit-submit {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.msg-edit-cancel {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.msg-edit-cancel:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.msg-edit-submit {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.msg-edit-submit:hover { background: var(--accent-hover); }
|
||||
|
||||
.msg-text { font-size: var(--msg-font, 14px); line-height: 1.7; }
|
||||
|
||||
/* ── Markdown Content ────────────────────── */
|
||||
@@ -755,13 +828,14 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: none; align-items: center; justify-content: center;
|
||||
z-index: 1000; backdrop-filter: blur(2px);
|
||||
padding: 2rem; /* keeps modal away from viewport edges at any zoom */
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); width: 90%; max-width: 520px;
|
||||
max-height: 85vh; display: flex; flex-direction: column;
|
||||
max-height: 100%; display: flex; flex-direction: column;
|
||||
animation: modal-in 0.15s ease;
|
||||
}
|
||||
.modal-wide { max-width: 700px; }
|
||||
@@ -788,13 +862,44 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.settings-section:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
.settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
/* Modal tab bars — shared horizontal scroll with arrow navigation */
|
||||
.modal-tabs-wrap {
|
||||
position: relative; flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border); background: var(--bg-raised);
|
||||
}
|
||||
.modal-tabs {
|
||||
display: flex; padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border); background: var(--bg-raised);
|
||||
overflow-x: auto; overflow-y: hidden;
|
||||
scrollbar-width: none; -ms-overflow-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-tabs::-webkit-scrollbar { display: none; }
|
||||
/* When wrapped, the wrapper owns the border */
|
||||
.modal-tabs-wrap .modal-tabs { border-bottom: none; background: none; }
|
||||
|
||||
.tab-arrow {
|
||||
position: absolute; top: 0; bottom: 1px; /* 1px for border */ width: 28px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-raised); border: none; color: var(--text-2);
|
||||
cursor: pointer; font-size: 16px; z-index: 2; opacity: 0;
|
||||
pointer-events: none; transition: opacity 0.15s ease;
|
||||
}
|
||||
.tab-arrow.visible { opacity: 1; pointer-events: auto; }
|
||||
.tab-arrow:hover { color: var(--accent); }
|
||||
.tab-arrow-left { left: 0; padding-left: 2px; box-shadow: 4px 0 8px -2px rgba(0,0,0,0.15); }
|
||||
.tab-arrow-right { right: 0; padding-right: 2px; box-shadow: -4px 0 8px -2px rgba(0,0,0,0.15); }
|
||||
|
||||
/* Settings tabs */
|
||||
.settings-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); gap: 0; }
|
||||
.settings-tabs { gap: 0; }
|
||||
.settings-tabs .settings-tab {
|
||||
padding: 10px 14px; background: none; border: none; border-radius: 0;
|
||||
color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font);
|
||||
border-bottom: 2px solid transparent; transition: color var(--transition);
|
||||
outline: none; -webkit-appearance: none; appearance: none;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.settings-tabs .settings-tab:hover { color: var(--text-2); }
|
||||
.settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
@@ -817,10 +922,11 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; }
|
||||
|
||||
/* Admin tabs */
|
||||
.admin-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); }
|
||||
.admin-tabs { }
|
||||
.admin-tab {
|
||||
padding: 10px 14px; background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
@@ -937,9 +1043,9 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
|
||||
/* ── Debug Modal ─────────────────────────── */
|
||||
|
||||
.debug-modal { max-width: 900px; height: 80vh; overflow: hidden; }
|
||||
.debug-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); }
|
||||
.debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; }
|
||||
.debug-modal { max-width: 900px; height: 100%; overflow: hidden; }
|
||||
.debug-tabs { }
|
||||
.debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; }
|
||||
.debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.debug-modal-body { flex: 1; overflow: hidden; padding: 0 !important; }
|
||||
.debug-tab-content { height: 100%; display: flex; flex-direction: column; }
|
||||
@@ -980,4 +1086,8 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.input-area { padding: 0 0.5rem 0.5rem; }
|
||||
.input-wrap textarea { font-size: 16px; } /* prevent iOS zoom */
|
||||
.mobile-menu-btn { display: flex; }
|
||||
.modal-overlay { padding: 0.75rem; }
|
||||
.modal { width: 100%; }
|
||||
.tab-arrow { width: 36px; font-size: 20px; } /* larger touch target */
|
||||
.modal-tabs { padding: 0 12px; }
|
||||
}
|
||||
|
||||
@@ -118,9 +118,6 @@
|
||||
<div class="input-wrap">
|
||||
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
||||
<div class="input-actions">
|
||||
<button class="action-btn" id="regenerateBtn" style="display:none" title="Regenerate">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
@@ -201,7 +198,7 @@
|
||||
<div class="modal-overlay" id="settingsModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn">✕</button></div>
|
||||
<div class="settings-tabs">
|
||||
<div class="modal-tabs settings-tabs">
|
||||
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
||||
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
||||
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
|
||||
@@ -283,7 +280,7 @@
|
||||
<div class="modal-overlay" id="adminModal">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn">✕</button></div>
|
||||
<div class="admin-tabs">
|
||||
<div class="modal-tabs admin-tabs">
|
||||
<button class="admin-tab active" data-tab="users">Users</button>
|
||||
<button class="admin-tab" data-tab="providers">Providers</button>
|
||||
<button class="admin-tab" data-tab="models">Models</button>
|
||||
@@ -408,7 +405,7 @@
|
||||
<div class="modal-overlay" id="debugModal">
|
||||
<div class="modal debug-modal">
|
||||
<div class="modal-header"><h2>Debug Log</h2><button class="modal-close" onclick="closeDebugModal()">✕</button></div>
|
||||
<div class="debug-tabs">
|
||||
<div class="modal-tabs debug-tabs">
|
||||
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
|
||||
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
|
||||
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
|
||||
|
||||
@@ -122,6 +122,55 @@ const API = {
|
||||
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
|
||||
// ── Message Tree (forking) ───────────────
|
||||
|
||||
getActivePath(channelId) {
|
||||
return this._get(`/api/v1/channels/${channelId}/path`);
|
||||
},
|
||||
|
||||
editMessage(channelId, messageId, content) {
|
||||
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
|
||||
},
|
||||
|
||||
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId) {
|
||||
const body = {};
|
||||
if (model) body.model = model;
|
||||
if (presetId) body.preset_id = presetId;
|
||||
if (apiConfigId) body.api_config_id = apiConfigId;
|
||||
|
||||
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
if (await this.refresh()) {
|
||||
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
|
||||
updateCursor(channelId, activeLeafId) {
|
||||
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
|
||||
},
|
||||
|
||||
listSiblings(channelId, messageId) {
|
||||
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
|
||||
},
|
||||
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -500,11 +500,11 @@ const DebugLog = {
|
||||
|
||||
function openDebugModal() {
|
||||
DebugLog.render();
|
||||
document.getElementById('debugModal').classList.add('active');
|
||||
openModal('debugModal');
|
||||
}
|
||||
|
||||
function closeDebugModal() {
|
||||
document.getElementById('debugModal').classList.remove('active');
|
||||
closeModal('debugModal');
|
||||
}
|
||||
|
||||
function switchDebugTab(tab) {
|
||||
|
||||
81
src/js/ui.js
81
src/js/ui.js
@@ -110,15 +110,37 @@ const UI = {
|
||||
assistantLabel = m?.name || msg.model;
|
||||
}
|
||||
}
|
||||
|
||||
// Branch indicator: show ‹ 2/3 › when siblings exist
|
||||
const hasSiblings = (msg.siblingCount || 1) > 1;
|
||||
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
|
||||
const sibTotal = msg.siblingCount || 1;
|
||||
const branchNav = hasSiblings ? `
|
||||
<div class="branch-nav">
|
||||
<button class="branch-arrow" onclick="switchSibling('${msg.id}', -1)"
|
||||
${sibPos <= 1 ? 'disabled' : ''} title="Previous version">‹</button>
|
||||
<span class="branch-pos">${sibPos}/${sibTotal}</span>
|
||||
<button class="branch-arrow" onclick="switchSibling('${msg.id}', 1)"
|
||||
${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">›</button>
|
||||
</div>` : '';
|
||||
|
||||
// Action buttons
|
||||
const msgId = msg.id ? esc(msg.id) : '';
|
||||
const editBtn = isUser && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
|
||||
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
|
||||
|
||||
return `
|
||||
<div class="message ${msg.role}">
|
||||
<div class="message ${msg.role}" data-msg-id="${msgId}">
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head">
|
||||
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
||||
${branchNav}
|
||||
${time ? `<span class="msg-time">${time}</span>` : ''}
|
||||
<div class="msg-actions">
|
||||
${editBtn}
|
||||
${regenBtn}
|
||||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,6 +159,48 @@ const UI = {
|
||||
</div>`;
|
||||
},
|
||||
|
||||
showEditInline(messageId, currentContent) {
|
||||
const msgEl = document.querySelector(`.message[data-msg-id="${messageId}"]`);
|
||||
if (!msgEl) return;
|
||||
|
||||
const textEl = msgEl.querySelector('.msg-text');
|
||||
if (!textEl) return;
|
||||
|
||||
// Replace message text with an edit form
|
||||
const escaped = currentContent.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
textEl.innerHTML = `
|
||||
<textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea>
|
||||
<div class="msg-edit-actions">
|
||||
<button class="msg-edit-cancel" onclick="cancelEdit()">Cancel</button>
|
||||
<button class="msg-edit-submit" onclick="submitEdit('${messageId}', document.getElementById('editInput_${messageId}').value)">Submit & Regenerate</button>
|
||||
</div>`;
|
||||
|
||||
// Focus and select all
|
||||
const textarea = document.getElementById(`editInput_${messageId}`);
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
// Auto-resize
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
});
|
||||
// Ctrl+Enter to submit
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submitEdit(messageId, textarea.value);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── Streaming ────────────────────────────
|
||||
|
||||
async streamResponse(response, messages, modelName) {
|
||||
@@ -376,7 +440,8 @@ const UI = {
|
||||
},
|
||||
|
||||
showRegenerate(show) {
|
||||
document.getElementById('regenerateBtn').style.display = show ? 'flex' : 'none';
|
||||
// Bottom-bar regenerate button removed in v0.7.1.
|
||||
// Regen is now per-message via inline buttons.
|
||||
},
|
||||
|
||||
// ── Toast ────────────────────────────────
|
||||
@@ -414,9 +479,9 @@ const UI = {
|
||||
|
||||
UI.loadProfileIntoSettings();
|
||||
UI.switchSettingsTab('general');
|
||||
document.getElementById('settingsModal').classList.add('active');
|
||||
openModal('settingsModal');
|
||||
},
|
||||
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
|
||||
closeSettings() { closeModal('settingsModal'); },
|
||||
|
||||
switchSettingsTab(tab) {
|
||||
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
|
||||
@@ -473,6 +538,10 @@ const UI = {
|
||||
if (splash) splash.style.zoom = z;
|
||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
||||
// Recheck tab overflow after layout settles with new zoom
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
|
||||
});
|
||||
},
|
||||
|
||||
async checkUserProvidersAllowed() {
|
||||
@@ -527,8 +596,8 @@ const UI = {
|
||||
|
||||
// ── Admin Modal ──────────────────────────
|
||||
|
||||
openAdmin() { document.getElementById('adminModal').classList.add('active'); UI.switchAdminTab('users'); },
|
||||
closeAdmin() { document.getElementById('adminModal').classList.remove('active'); },
|
||||
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
|
||||
closeAdmin() { closeModal('adminModal'); },
|
||||
|
||||
async switchAdminTab(tab) {
|
||||
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||
|
||||
Reference in New Issue
Block a user