v1.2.1 - Fix session data loss on switch, add terminal panel for coding/agentic modes
This commit is contained in:
274
www/js/app.js
274
www/js/app.js
@@ -10,7 +10,6 @@
|
||||
brainstorm: 'You are a creative brainstorming partner. Generate diverse ideas, explore unconventional angles, build on concepts, and help evaluate trade-offs. Think freely and expansively. Present ideas in organized lists or tables when appropriate.',
|
||||
agentic: 'You are an autonomous coding agent. Break down complex tasks into clear steps. Write production-quality code with proper error handling, tests, and documentation. Think through the architecture before coding. Use tool-calling format when appropriate: [SEARCH], [CREATE_FILE], [EDIT_FILE], [RUN_COMMAND]. Always verify your work.'
|
||||
};
|
||||
var MODE_EMOJIS = { chat: '\u{1F4AC}', coding: '\u{1F4BB}', brainstorm: '\u{1F4A1}', agentic: '\u{1F916}' };
|
||||
|
||||
var state = {
|
||||
apiKey: '',
|
||||
@@ -25,7 +24,11 @@
|
||||
conversations: [],
|
||||
activeConversationId: null,
|
||||
isGenerating: false,
|
||||
abortController: null
|
||||
abortController: null,
|
||||
streamingConvId: null,
|
||||
streamingContent: '',
|
||||
streamingResponseDiv: null,
|
||||
terminalOpen: false
|
||||
};
|
||||
|
||||
function $(sel) { return document.querySelector(sel); }
|
||||
@@ -42,6 +45,7 @@
|
||||
state.webSearch = localStorage.getItem(STORAGE_KEY + 'webSearch') === 'true';
|
||||
state.currentMode = localStorage.getItem(STORAGE_KEY + 'currentMode') || 'chat';
|
||||
state.theme = localStorage.getItem(STORAGE_KEY + 'theme') || 'dark';
|
||||
state.terminalOpen = localStorage.getItem(STORAGE_KEY + 'terminalOpen') === 'true';
|
||||
var convData = localStorage.getItem(STORAGE_KEY + 'conversations');
|
||||
state.conversations = convData ? JSON.parse(convData) : [];
|
||||
state.activeConversationId = localStorage.getItem(STORAGE_KEY + 'activeConv') || null;
|
||||
@@ -59,6 +63,7 @@
|
||||
localStorage.setItem(STORAGE_KEY + 'webSearch', state.webSearch.toString());
|
||||
localStorage.setItem(STORAGE_KEY + 'currentMode', state.currentMode);
|
||||
localStorage.setItem(STORAGE_KEY + 'theme', state.theme);
|
||||
localStorage.setItem(STORAGE_KEY + 'terminalOpen', state.terminalOpen.toString());
|
||||
localStorage.setItem(STORAGE_KEY + 'conversations', JSON.stringify(state.conversations));
|
||||
localStorage.setItem(STORAGE_KEY + 'activeConv', state.activeConversationId || '');
|
||||
} catch(e) { console.error('Save state error:', e); }
|
||||
@@ -66,12 +71,36 @@
|
||||
|
||||
function genId() { return Date.now().toString(36) + Math.random().toString(36).substr(2, 9); }
|
||||
|
||||
function getConversation() {
|
||||
if (!state.activeConversationId) return null;
|
||||
return state.conversations.find(function(c) { return c.id === state.activeConversationId; });
|
||||
function getConversation(id) {
|
||||
var targetId = id || state.activeConversationId;
|
||||
if (!targetId) return null;
|
||||
return state.conversations.find(function(c) { return c.id === targetId; });
|
||||
}
|
||||
|
||||
function flushStreamingToConversation() {
|
||||
if (state.streamingConvId && state.streamingContent) {
|
||||
var conv = getConversation(state.streamingConvId);
|
||||
if (conv) {
|
||||
var lastMsg = conv.messages[conv.messages.length - 1];
|
||||
if (lastMsg && lastMsg.role === 'assistant' && lastMsg._streaming) {
|
||||
lastMsg.content = state.streamingContent;
|
||||
delete lastMsg._streaming;
|
||||
} else {
|
||||
conv.messages.push({ role: 'assistant', content: state.streamingContent });
|
||||
}
|
||||
saveState();
|
||||
}
|
||||
}
|
||||
state.streamingConvId = null;
|
||||
state.streamingContent = '';
|
||||
state.streamingResponseDiv = null;
|
||||
}
|
||||
|
||||
function newConversation() {
|
||||
flushStreamingToConversation();
|
||||
if (state.isGenerating) {
|
||||
stopGeneration();
|
||||
}
|
||||
var conv = {
|
||||
id: genId(),
|
||||
title: 'New Chat',
|
||||
@@ -81,13 +110,27 @@
|
||||
};
|
||||
state.conversations.unshift(conv);
|
||||
state.activeConversationId = conv.id;
|
||||
state.isGenerating = false;
|
||||
state.abortController = null;
|
||||
saveState();
|
||||
renderConversationList();
|
||||
renderMessages();
|
||||
updateHeader();
|
||||
updateSendButton();
|
||||
updateTerminalVisibility();
|
||||
}
|
||||
|
||||
function switchConversation(id) {
|
||||
if (id === state.activeConversationId) {
|
||||
closeSidebar();
|
||||
return;
|
||||
}
|
||||
flushStreamingToConversation();
|
||||
if (state.isGenerating) {
|
||||
stopGeneration();
|
||||
state.isGenerating = false;
|
||||
state.abortController = null;
|
||||
}
|
||||
state.activeConversationId = id;
|
||||
var conv = getConversation();
|
||||
if (conv) {
|
||||
@@ -98,10 +141,17 @@
|
||||
renderConversationList();
|
||||
renderMessages();
|
||||
updateHeader();
|
||||
updateSendButton();
|
||||
updateTerminalVisibility();
|
||||
closeSidebar();
|
||||
}
|
||||
|
||||
function deleteConversation(id) {
|
||||
if (state.streamingConvId === id) {
|
||||
flushStreamingToConversation();
|
||||
if (state.isGenerating) stopGeneration();
|
||||
state.isGenerating = false;
|
||||
}
|
||||
state.conversations = state.conversations.filter(function(c) { return c.id !== id; });
|
||||
if (state.activeConversationId === id) {
|
||||
state.activeConversationId = state.conversations.length > 0 ? state.conversations[0].id : null;
|
||||
@@ -110,6 +160,7 @@
|
||||
renderConversationList();
|
||||
renderMessages();
|
||||
updateHeader();
|
||||
updateTerminalVisibility();
|
||||
}
|
||||
|
||||
function updateHeader() {
|
||||
@@ -135,7 +186,10 @@
|
||||
state.conversations.forEach(function(conv) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'conv-item' + (conv.id === state.activeConversationId ? ' active' : '');
|
||||
div.innerHTML = '<span class="conv-title">' + escapeHtml(conv.title) + '</span>' +
|
||||
var msgCount = conv.messages.length;
|
||||
div.innerHTML = '<span class="conv-title">' + escapeHtml(conv.title) +
|
||||
(msgCount > 0 ? ' <span style="color:var(--text-muted);font-size:11px">(' + msgCount + ')</span>' : '') +
|
||||
'</span>' +
|
||||
'<button class="conv-delete" data-id="' + conv.id + '">×</button>';
|
||||
div.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('conv-delete')) {
|
||||
@@ -204,6 +258,7 @@
|
||||
appendMessage(msg.role, msg.content, container, false);
|
||||
});
|
||||
container.scrollTop = container.scrollHeight;
|
||||
updateTerminalContent();
|
||||
}
|
||||
|
||||
function appendMessage(role, content, container, animate) {
|
||||
@@ -270,12 +325,15 @@
|
||||
}
|
||||
|
||||
conv.messages.push({ role: 'user', content: text });
|
||||
saveState();
|
||||
input.value = '';
|
||||
autoResize(input);
|
||||
updateSendButton();
|
||||
appendMessage('user', text);
|
||||
|
||||
state.isGenerating = true;
|
||||
state.streamingConvId = conv.id;
|
||||
state.streamingContent = '';
|
||||
updateSendButton();
|
||||
showThinking();
|
||||
|
||||
@@ -283,7 +341,7 @@
|
||||
var systemPrompt = MODE_PROMPTS[state.currentMode] || MODE_PROMPTS.chat;
|
||||
var apiMessages = [{ role: 'system', content: systemPrompt }];
|
||||
conv.messages.forEach(function(m) {
|
||||
if (m.role === 'user' || m.role === 'assistant') {
|
||||
if (m.role === 'user' || (m.role === 'assistant' && !m._streaming)) {
|
||||
apiMessages.push({ role: m.role, content: m.content });
|
||||
}
|
||||
});
|
||||
@@ -305,6 +363,7 @@
|
||||
|
||||
removeThinking();
|
||||
var responseDiv = appendMessage('assistant', '');
|
||||
state.streamingResponseDiv = responseDiv;
|
||||
|
||||
if (state.streaming) {
|
||||
await streamResponse(requestBody, responseDiv, conv);
|
||||
@@ -312,6 +371,7 @@
|
||||
var result = await apiRequest(requestBody);
|
||||
var content = result.choices[0].message.content;
|
||||
updateStreamingMessage(responseDiv, content);
|
||||
state.streamingContent = content;
|
||||
conv.messages.push({ role: 'assistant', content: content });
|
||||
}
|
||||
} catch(err) {
|
||||
@@ -319,11 +379,17 @@
|
||||
if (err.name !== 'AbortError') {
|
||||
appendMessage('system', 'Error: ' + (err.message || 'Request failed'));
|
||||
}
|
||||
if (state.streamingContent) {
|
||||
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false });
|
||||
}
|
||||
} finally {
|
||||
state.isGenerating = false;
|
||||
state.abortController = null;
|
||||
state.streamingConvId = null;
|
||||
state.streamingResponseDiv = null;
|
||||
updateSendButton();
|
||||
saveState();
|
||||
updateTerminalContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +412,8 @@
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
var _streamAutoSaveCounter = 0;
|
||||
|
||||
async function streamResponse(body, responseDiv, conv) {
|
||||
state.abortController = new AbortController();
|
||||
body.stream = true;
|
||||
@@ -372,6 +440,7 @@
|
||||
var decoder = new TextDecoder();
|
||||
var fullContent = '';
|
||||
var buffer = '';
|
||||
_streamAutoSaveCounter = 0;
|
||||
|
||||
while (true) {
|
||||
var chunk = await reader.read();
|
||||
@@ -392,19 +461,39 @@
|
||||
var delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta;
|
||||
if (delta && delta.content) {
|
||||
fullContent += delta.content;
|
||||
updateStreamingMessage(responseDiv, fullContent);
|
||||
state.streamingContent = fullContent;
|
||||
if (responseDiv && responseDiv.parentElement) {
|
||||
updateStreamingMessage(responseDiv, fullContent);
|
||||
}
|
||||
_streamAutoSaveCounter++;
|
||||
if (_streamAutoSaveCounter % 20 === 0) {
|
||||
_saveStreamingProgress(conv, fullContent);
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
conv.messages.push({ role: 'assistant', content: fullContent });
|
||||
state.streamingContent = '';
|
||||
}
|
||||
|
||||
function _saveStreamingProgress(conv, content) {
|
||||
if (!conv) return;
|
||||
var last = conv.messages[conv.messages.length - 1];
|
||||
if (last && last.role === 'assistant' && last._streaming) {
|
||||
last.content = content;
|
||||
} else {
|
||||
conv.messages.push({ role: 'assistant', content: content, _streaming: true });
|
||||
}
|
||||
saveState();
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
if (state.abortController) {
|
||||
state.abortController.abort();
|
||||
}
|
||||
flushStreamingToConversation();
|
||||
}
|
||||
|
||||
function updateSendButton() {
|
||||
@@ -457,6 +546,170 @@
|
||||
applyTheme(state.theme === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// ---- Terminal Panel ----
|
||||
|
||||
function parseTerminalEntries(content) {
|
||||
if (!content) return [];
|
||||
var entries = [];
|
||||
|
||||
var toolRegex = /\[(CREATE_FILE|EDIT_FILE|DELETE_FILE|RUN_COMMAND|SEARCH|READ_FILE|BUILD|TEST)\]\s*\(([^)]*)\)\s*\n?([\s\S]*?)(?=\n\[|$)/gi;
|
||||
var match;
|
||||
while ((match = toolRegex.exec(content)) !== null) {
|
||||
entries.push({
|
||||
type: 'tool',
|
||||
action: match[1],
|
||||
target: match[2].trim(),
|
||||
body: match[3].trim()
|
||||
});
|
||||
}
|
||||
|
||||
var codeBlockRegex = /```(\w*)\n([\s\S]*?)```/gi;
|
||||
var idx = 0;
|
||||
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||
var isTool = false;
|
||||
for (var e = 0; e < entries.length; e++) {
|
||||
if (entries[e].type === 'tool' && match.index >= content.indexOf(entries[e].body) - 20) {
|
||||
isTool = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isTool) {
|
||||
idx++;
|
||||
var firstLine = match[2].trim().split('\n')[0];
|
||||
var isFilePath = /^(\/|\.\/|\.\.\/|[A-Za-z]:\\|[a-zA-Z0-9_\-]+\.[a-zA-Z]{1,4}$)/.test(firstLine) && firstLine.length < 120 && firstLine.split('\n').length === 1;
|
||||
entries.push({
|
||||
type: 'code',
|
||||
lang: match[1] || 'text',
|
||||
code: match[2].trim(),
|
||||
index: idx,
|
||||
fileName: isFilePath ? firstLine : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort(function(a, b) {
|
||||
return content.indexOf(a.type === 'tool' ? '[' + a.action : '```' + (a.lang || '')) -
|
||||
content.indexOf(b.type === 'tool' ? '[' + b.action : '```' + (b.lang || ''));
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function renderTerminalEntry(entry) {
|
||||
if (entry.type === 'tool') {
|
||||
var actionIcon = { CREATE_FILE: '+', EDIT_FILE: '~', DELETE_FILE: '-', RUN_COMMAND: '>', SEARCH: '?', READ_FILE: 'R', BUILD: 'B', TEST: 'T' };
|
||||
var actionColor = { CREATE_FILE: 'var(--success)', EDIT_FILE: 'var(--warning)', DELETE_FILE: 'var(--danger)', RUN_COMMAND: 'var(--accent)', SEARCH: 'var(--text-secondary)', READ_FILE: 'var(--text-muted)', BUILD: 'var(--accent)', TEST: 'var(--success)' };
|
||||
var icon = actionIcon[entry.action] || '>';
|
||||
var color = actionColor[entry.action] || 'var(--accent)';
|
||||
var html = '<div class="term-entry term-tool" style="border-left:3px solid ' + color + '">';
|
||||
html += '<div class="term-tool-header"><span class="term-action" style="color:' + color + '">[' + icon + '] ' + escapeHtml(entry.action) + '</span>';
|
||||
if (entry.target) html += ' <span class="term-target">' + escapeHtml(entry.target) + '</span>';
|
||||
html += '</div>';
|
||||
if (entry.body) {
|
||||
html += '<pre class="term-code">' + escapeHtml(entry.body.substring(0, 2000)) + (entry.body.length > 2000 ? '\n... (truncated)' : '') + '</pre>';
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
if (entry.type === 'code') {
|
||||
var label = entry.fileName ? escapeHtml(entry.fileName) : escapeHtml(entry.lang || 'code');
|
||||
var displayCode = entry.fileName ? entry.code.split('\n').slice(1).join('\n') : entry.code;
|
||||
if (!displayCode.trim()) displayCode = entry.code;
|
||||
var html = '<div class="term-entry term-code-block">';
|
||||
html += '<div class="term-file-header"><span class="term-lang">' + label + '</span>';
|
||||
html += '<button class="term-copy-btn" data-code="' + escapeHtml(entry.code).replace(/"/g, '"') + '">Copy</button></div>';
|
||||
html += '<pre class="term-code">' + escapeHtml(displayCode.substring(0, 3000)) + (displayCode.length > 3000 ? '\n... (truncated)' : '') + '</pre>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function updateTerminalContent() {
|
||||
var termBody = $('#terminal-body');
|
||||
if (!termBody) return;
|
||||
termBody.innerHTML = '';
|
||||
|
||||
var conv = getConversation();
|
||||
if (!conv) return;
|
||||
|
||||
var lastAssistant = null;
|
||||
for (var i = conv.messages.length - 1; i >= 0; i--) {
|
||||
if (conv.messages[i].role === 'assistant') {
|
||||
lastAssistant = conv.messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAssistant && !state.streamingContent) {
|
||||
termBody.innerHTML = '<div class="term-empty">No code output yet. Use Coding or Agentic mode to generate code.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var content = state.streamingContent || (lastAssistant ? lastAssistant.content : '');
|
||||
var entries = parseTerminalEntries(content);
|
||||
|
||||
if (entries.length === 0) {
|
||||
termBody.innerHTML = '<div class="term-empty">No structured code blocks or tool calls detected in response.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach(function(entry) {
|
||||
termBody.innerHTML += renderTerminalEntry(entry);
|
||||
});
|
||||
|
||||
termBody.querySelectorAll('.term-copy-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var code = this.getAttribute('data-code').replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
navigator.clipboard.writeText(code).then(function() {
|
||||
this.textContent = 'Copied!';
|
||||
setTimeout(function() { this.textContent = 'Copy'; }.bind(this), 2000);
|
||||
}.bind(this));
|
||||
});
|
||||
});
|
||||
|
||||
if (state.terminalOpen) {
|
||||
termBody.scrollTop = termBody.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTerminalVisibility() {
|
||||
var panel = $('#terminal-panel');
|
||||
var toggleBtn = $('#terminal-toggle');
|
||||
if (!panel || !toggleBtn) return;
|
||||
|
||||
var isDevMode = (state.currentMode === 'coding' || state.currentMode === 'agentic');
|
||||
if (isDevMode) {
|
||||
panel.style.display = 'flex';
|
||||
toggleBtn.style.display = 'flex';
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
toggleBtn.style.display = 'none';
|
||||
state.terminalOpen = false;
|
||||
}
|
||||
|
||||
if (state.terminalOpen && isDevMode) {
|
||||
panel.classList.add('open');
|
||||
} else {
|
||||
panel.classList.remove('open');
|
||||
}
|
||||
|
||||
var label = toggleBtn.querySelector('.terminal-label');
|
||||
if (label) label.textContent = state.terminalOpen ? 'Hide Terminal' : 'Show Terminal';
|
||||
}
|
||||
|
||||
function toggleTerminal() {
|
||||
state.terminalOpen = !state.terminalOpen;
|
||||
var panel = $('#terminal-panel');
|
||||
if (panel) panel.classList.toggle('open', state.terminalOpen);
|
||||
var label = $('#terminal-toggle .terminal-label');
|
||||
if (label) label.textContent = state.terminalOpen ? 'Hide Terminal' : 'Show Terminal';
|
||||
if (state.terminalOpen) updateTerminalContent();
|
||||
saveState();
|
||||
}
|
||||
|
||||
// ---- Rest of init ----
|
||||
|
||||
async function testConnection(apiKey, baseUrl) {
|
||||
var url = (baseUrl || state.baseUrl).replace(/\/+$/, '') + '/chat/completions';
|
||||
var resp = await fetch(url, {
|
||||
@@ -529,6 +782,7 @@
|
||||
renderMessages();
|
||||
updateHeader();
|
||||
updateModeSelector();
|
||||
updateTerminalVisibility();
|
||||
$('#api-token').value = state.apiKey;
|
||||
$('#base-url').value = state.baseUrl;
|
||||
}
|
||||
@@ -591,6 +845,7 @@
|
||||
state.currentMode = this.dataset.mode;
|
||||
updateModeSelector();
|
||||
updateHeader();
|
||||
updateTerminalVisibility();
|
||||
saveState();
|
||||
});
|
||||
});
|
||||
@@ -632,6 +887,8 @@
|
||||
applyTheme(this.checked ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
$('#terminal-toggle').addEventListener('click', toggleTerminal);
|
||||
|
||||
$('#export-btn').addEventListener('click', exportConversations);
|
||||
|
||||
$('#clear-btn').addEventListener('click', function() {
|
||||
@@ -642,6 +899,7 @@
|
||||
renderConversationList();
|
||||
renderMessages();
|
||||
updateHeader();
|
||||
updateTerminalContent();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user