917 lines
35 KiB
JavaScript
917 lines
35 KiB
JavaScript
(function() {
|
|
'use strict';
|
|
|
|
var DEFAULT_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
|
|
var DEFAULT_MODEL = 'glm-5.1';
|
|
var STORAGE_KEY = 'zai_chat_';
|
|
var MODE_PROMPTS = {
|
|
chat: 'You are a helpful, knowledgeable AI assistant. Be concise and accurate.',
|
|
coding: 'You are an expert coding assistant. Write clean, efficient, well-documented code. Always use markdown code blocks with language tags. Explain your approach briefly before and after code. Handle edge cases and errors properly.',
|
|
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 state = {
|
|
apiKey: '',
|
|
baseUrl: DEFAULT_BASE_URL,
|
|
model: DEFAULT_MODEL,
|
|
temperature: 0.7,
|
|
maxTokens: 4096,
|
|
streaming: true,
|
|
webSearch: false,
|
|
currentMode: 'chat',
|
|
theme: 'dark',
|
|
conversations: [],
|
|
activeConversationId: null,
|
|
isGenerating: false,
|
|
abortController: null,
|
|
streamingConvId: null,
|
|
streamingContent: '',
|
|
streamingResponseDiv: null,
|
|
terminalOpen: false
|
|
};
|
|
|
|
function $(sel) { return document.querySelector(sel); }
|
|
function $$(sel) { return document.querySelectorAll(sel); }
|
|
|
|
function loadState() {
|
|
try {
|
|
state.apiKey = localStorage.getItem(STORAGE_KEY + 'apiKey') || '';
|
|
state.baseUrl = localStorage.getItem(STORAGE_KEY + 'baseUrl') || DEFAULT_BASE_URL;
|
|
state.model = localStorage.getItem(STORAGE_KEY + 'model') || DEFAULT_MODEL;
|
|
state.temperature = parseFloat(localStorage.getItem(STORAGE_KEY + 'temperature')) || 0.7;
|
|
state.maxTokens = parseInt(localStorage.getItem(STORAGE_KEY + 'maxTokens')) || 4096;
|
|
state.streaming = localStorage.getItem(STORAGE_KEY + 'streaming') !== 'false';
|
|
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;
|
|
} catch(e) { console.error('Load state error:', e); }
|
|
}
|
|
|
|
function saveState() {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY + 'apiKey', state.apiKey);
|
|
localStorage.setItem(STORAGE_KEY + 'baseUrl', state.baseUrl);
|
|
localStorage.setItem(STORAGE_KEY + 'model', state.model);
|
|
localStorage.setItem(STORAGE_KEY + 'temperature', state.temperature.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'maxTokens', state.maxTokens.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'streaming', state.streaming.toString());
|
|
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); }
|
|
}
|
|
|
|
function genId() { return Date.now().toString(36) + Math.random().toString(36).substr(2, 9); }
|
|
|
|
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',
|
|
mode: state.currentMode,
|
|
messages: [],
|
|
createdAt: Date.now()
|
|
};
|
|
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) {
|
|
state.currentMode = conv.mode || 'chat';
|
|
updateModeSelector();
|
|
}
|
|
saveState();
|
|
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;
|
|
}
|
|
saveState();
|
|
renderConversationList();
|
|
renderMessages();
|
|
updateHeader();
|
|
updateTerminalVisibility();
|
|
}
|
|
|
|
function updateHeader() {
|
|
var conv = getConversation();
|
|
$('#conversation-title').textContent = conv ? conv.title : 'Z.AI Chat';
|
|
$('#current-mode-label').textContent = state.currentMode.charAt(0).toUpperCase() + state.currentMode.slice(1);
|
|
}
|
|
|
|
function showScreen(name) {
|
|
$$('.screen').forEach(function(s) { s.classList.remove('active'); });
|
|
$('#' + name + '-screen').classList.add('active');
|
|
}
|
|
|
|
function autoResize(el) {
|
|
el.style.height = 'auto';
|
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
|
}
|
|
|
|
function renderConversationList() {
|
|
var list = $('#conversation-list');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
state.conversations.forEach(function(conv) {
|
|
var div = document.createElement('div');
|
|
div.className = 'conv-item' + (conv.id === state.activeConversationId ? ' active' : '');
|
|
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')) {
|
|
e.stopPropagation();
|
|
deleteConversation(e.target.dataset.id);
|
|
return;
|
|
}
|
|
switchConversation(conv.id);
|
|
});
|
|
list.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
var d = document.createElement('div');
|
|
d.textContent = text;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
if (typeof marked !== 'undefined') {
|
|
marked.setOptions({
|
|
highlight: function(code, lang) {
|
|
if (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) {
|
|
try { return hljs.highlight(code, { language: lang }).value; } catch(e) {}
|
|
}
|
|
return code;
|
|
},
|
|
breaks: true,
|
|
gfm: true
|
|
});
|
|
return marked.parse(text);
|
|
}
|
|
return text.replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
|
}
|
|
|
|
function addCodeHeaders(container) {
|
|
container.querySelectorAll('pre code').forEach(function(block) {
|
|
var pre = block.parentElement;
|
|
var lang = (block.className.match(/language-(\w+)/) || [])[1] || 'code';
|
|
if (!pre.previousElementSibling || !pre.previousElementSibling.classList.contains('code-header')) {
|
|
var header = document.createElement('div');
|
|
header.className = 'code-header';
|
|
header.innerHTML = '<span>' + escapeHtml(lang) + '</span><button class="copy-btn">Copy</button>';
|
|
pre.parentElement.insertBefore(header, pre);
|
|
header.querySelector('.copy-btn').addEventListener('click', function() {
|
|
navigator.clipboard.writeText(block.textContent).then(function() {
|
|
this.textContent = 'Copied!';
|
|
setTimeout(function() { this.textContent = 'Copy'; }.bind(this), 2000);
|
|
}.bind(this));
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderMessages() {
|
|
var container = $('#messages');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
var conv = getConversation();
|
|
if (!conv || conv.messages.length === 0) {
|
|
container.innerHTML = '<div class="message system">Start a conversation with Z.AI</div>';
|
|
return;
|
|
}
|
|
conv.messages.forEach(function(msg) {
|
|
appendMessage(msg.role, msg.content, container, false);
|
|
});
|
|
container.scrollTop = container.scrollHeight;
|
|
updateTerminalContent();
|
|
}
|
|
|
|
function appendMessage(role, content, container, animate) {
|
|
container = container || $('#messages');
|
|
var div = document.createElement('div');
|
|
div.className = 'message ' + role;
|
|
if (animate === false) div.style.animation = 'none';
|
|
|
|
if (role === 'assistant') {
|
|
div.innerHTML = renderMarkdown(content);
|
|
addCodeHeaders(div);
|
|
} else {
|
|
div.textContent = content;
|
|
}
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
return div;
|
|
}
|
|
|
|
function updateStreamingMessage(div, content) {
|
|
div.innerHTML = renderMarkdown(content);
|
|
addCodeHeaders(div);
|
|
$('#messages').scrollTop = $('#messages').scrollHeight;
|
|
}
|
|
|
|
function showThinking() {
|
|
var container = $('#messages');
|
|
var div = document.createElement('div');
|
|
div.className = 'message assistant';
|
|
div.id = 'thinking-msg';
|
|
div.innerHTML = '<div class="thinking-indicator"><div class="thinking-dots"><span></span><span></span><span></span></div> Thinking...</div>';
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function removeThinking() {
|
|
var el = $('#thinking-msg');
|
|
if (el) el.remove();
|
|
}
|
|
|
|
async function sendMessage() {
|
|
var input = $('#message-input');
|
|
var text = input.value.trim();
|
|
if (!text || state.isGenerating) return;
|
|
|
|
if (!state.apiKey) {
|
|
showScreen('setup');
|
|
return;
|
|
}
|
|
|
|
if (!state.activeConversationId) {
|
|
newConversation();
|
|
}
|
|
|
|
var conv = getConversation();
|
|
if (!conv) return;
|
|
|
|
conv.mode = state.currentMode;
|
|
|
|
if (conv.messages.length === 0) {
|
|
conv.title = text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
updateHeader();
|
|
renderConversationList();
|
|
}
|
|
|
|
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();
|
|
|
|
try {
|
|
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' && !m._streaming)) {
|
|
apiMessages.push({ role: m.role, content: m.content });
|
|
}
|
|
});
|
|
|
|
var requestBody = {
|
|
model: state.model,
|
|
messages: apiMessages,
|
|
temperature: state.temperature,
|
|
max_tokens: state.maxTokens,
|
|
stream: state.streaming
|
|
};
|
|
|
|
if (state.webSearch) {
|
|
requestBody.tools = [{
|
|
type: 'web_search',
|
|
web_search: { search_query: text, search_result: true }
|
|
}];
|
|
}
|
|
|
|
removeThinking();
|
|
var responseDiv = appendMessage('assistant', '');
|
|
state.streamingResponseDiv = responseDiv;
|
|
|
|
if (state.streaming) {
|
|
await streamResponse(requestBody, responseDiv, conv);
|
|
} else {
|
|
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) {
|
|
removeThinking();
|
|
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();
|
|
}
|
|
}
|
|
|
|
async function apiRequest(body) {
|
|
var url = state.baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
var resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + state.apiKey,
|
|
'Accept-Language': 'en-US,en'
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!resp.ok) {
|
|
var errData = {};
|
|
try { errData = await resp.json(); } catch(e) {}
|
|
throw new Error(errData.error?.message || 'API error ' + resp.status);
|
|
}
|
|
return await resp.json();
|
|
}
|
|
|
|
var _streamAutoSaveCounter = 0;
|
|
|
|
async function streamResponse(body, responseDiv, conv) {
|
|
state.abortController = new AbortController();
|
|
body.stream = true;
|
|
|
|
var url = state.baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
var resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + state.apiKey,
|
|
'Accept-Language': 'en-US,en'
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: state.abortController.signal
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
var errData = {};
|
|
try { errData = await resp.json(); } catch(e) {}
|
|
throw new Error(errData.error?.message || 'API error ' + resp.status);
|
|
}
|
|
|
|
var reader = resp.body.getReader();
|
|
var decoder = new TextDecoder();
|
|
var fullContent = '';
|
|
var buffer = '';
|
|
_streamAutoSaveCounter = 0;
|
|
|
|
while (true) {
|
|
var chunk = await reader.read();
|
|
if (chunk.done) break;
|
|
|
|
buffer += decoder.decode(chunk.value, { stream: true });
|
|
var lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i].trim();
|
|
if (!line || !line.startsWith('data:')) continue;
|
|
var data = line.substring(5).trim();
|
|
if (data === '[DONE]') break;
|
|
|
|
try {
|
|
var parsed = JSON.parse(data);
|
|
var delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta;
|
|
if (delta && delta.content) {
|
|
fullContent += delta.content;
|
|
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() {
|
|
var input = $('#message-input');
|
|
var sendBtn = $('#send-btn');
|
|
var stopBtn = $('#stop-btn');
|
|
|
|
if (state.isGenerating) {
|
|
sendBtn.style.display = 'none';
|
|
stopBtn.style.display = 'flex';
|
|
} else {
|
|
sendBtn.style.display = 'flex';
|
|
stopBtn.style.display = 'none';
|
|
sendBtn.disabled = !input.value.trim();
|
|
}
|
|
}
|
|
|
|
function updateModeSelector() {
|
|
$$('.mode-btn').forEach(function(btn) {
|
|
btn.classList.toggle('active', btn.dataset.mode === state.currentMode);
|
|
});
|
|
}
|
|
|
|
function openSidebar() {
|
|
$('#sidebar').classList.add('open');
|
|
$('#sidebar-overlay').classList.add('active');
|
|
}
|
|
|
|
function closeSidebar() {
|
|
$('#sidebar').classList.remove('open');
|
|
$('#sidebar-overlay').classList.remove('active');
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
state.theme = theme;
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
var headerBtn = $('#theme-toggle-header');
|
|
if (headerBtn) {
|
|
headerBtn.innerHTML = theme === 'dark' ? '☼' : '☾';
|
|
headerBtn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
|
|
}
|
|
var settingsToggle = $('#settings-darkmode');
|
|
if (settingsToggle) settingsToggle.checked = (theme === 'dark');
|
|
var metaTheme = document.querySelector('meta[name="theme-color"]');
|
|
if (metaTheme) metaTheme.content = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
|
saveState();
|
|
}
|
|
|
|
function toggleTheme() {
|
|
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, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + apiKey,
|
|
'Accept-Language': 'en-US,en'
|
|
},
|
|
body: JSON.stringify({
|
|
model: state.model,
|
|
messages: [{ role: 'user', content: 'Hi' }],
|
|
max_tokens: 10
|
|
})
|
|
});
|
|
if (!resp.ok) {
|
|
var errData = {};
|
|
try { errData = await resp.json(); } catch(e) {}
|
|
throw new Error(errData.error?.message || 'Connection failed (' + resp.status + ')');
|
|
}
|
|
return await resp.json();
|
|
}
|
|
|
|
function populateSettings() {
|
|
$('#settings-token').value = state.apiKey;
|
|
$('#settings-url').value = state.baseUrl;
|
|
$('#settings-model').value = state.model;
|
|
$('#settings-temp').value = state.temperature;
|
|
$('#temp-value').textContent = state.temperature;
|
|
$('#settings-tokens').value = state.maxTokens;
|
|
$('#tokens-value').textContent = state.maxTokens;
|
|
$('#settings-websearch').checked = state.webSearch;
|
|
$('#settings-streaming').checked = state.streaming;
|
|
}
|
|
|
|
function saveSettings() {
|
|
state.apiKey = $('#settings-token').value.trim();
|
|
state.baseUrl = $('#settings-url').value.trim();
|
|
state.model = $('#settings-model').value;
|
|
state.temperature = parseFloat($('#settings-temp').value);
|
|
state.maxTokens = parseInt($('#settings-tokens').value);
|
|
state.webSearch = $('#settings-websearch').checked;
|
|
state.streaming = $('#settings-streaming').checked;
|
|
saveState();
|
|
}
|
|
|
|
function exportConversations() {
|
|
var data = JSON.stringify(state.conversations, null, 2);
|
|
var blob = new Blob([data], { type: 'application/json' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'zai-chat-export-' + new Date().toISOString().slice(0, 10) + '.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function init() {
|
|
loadState();
|
|
|
|
if (state.apiKey) {
|
|
showScreen('chat');
|
|
if (state.activeConversationId) {
|
|
var conv = getConversation();
|
|
if (conv) {
|
|
state.currentMode = conv.mode || 'chat';
|
|
}
|
|
}
|
|
renderConversationList();
|
|
renderMessages();
|
|
updateHeader();
|
|
updateModeSelector();
|
|
updateTerminalVisibility();
|
|
$('#api-token').value = state.apiKey;
|
|
$('#base-url').value = state.baseUrl;
|
|
}
|
|
|
|
$('#connect-btn').addEventListener('click', async function() {
|
|
var btn = this;
|
|
var apiKey = $('#api-token').value.trim();
|
|
var baseUrl = $('#base-url').value;
|
|
var errorEl = $('#setup-error');
|
|
|
|
if (!apiKey) {
|
|
errorEl.textContent = 'Please enter your API key';
|
|
errorEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.querySelector('.btn-text').textContent = 'Connecting...';
|
|
btn.querySelector('.btn-loader').style.display = 'inline-block';
|
|
errorEl.style.display = 'none';
|
|
|
|
try {
|
|
await testConnection(apiKey, baseUrl);
|
|
state.apiKey = apiKey;
|
|
state.baseUrl = baseUrl;
|
|
saveState();
|
|
showScreen('chat');
|
|
newConversation();
|
|
} catch(err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.style.display = 'block';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.querySelector('.btn-text').textContent = 'Connect';
|
|
btn.querySelector('.btn-loader').style.display = 'none';
|
|
}
|
|
});
|
|
|
|
$('#api-token').addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') $('#connect-btn').click();
|
|
});
|
|
|
|
$('#message-input').addEventListener('input', function() {
|
|
autoResize(this);
|
|
updateSendButton();
|
|
});
|
|
|
|
$('#message-input').addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
});
|
|
|
|
$('#send-btn').addEventListener('click', sendMessage);
|
|
$('#stop-btn').addEventListener('click', stopGeneration);
|
|
|
|
$$('.mode-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
state.currentMode = this.dataset.mode;
|
|
updateModeSelector();
|
|
updateHeader();
|
|
updateTerminalVisibility();
|
|
saveState();
|
|
});
|
|
});
|
|
|
|
$('#menu-btn').addEventListener('click', openSidebar);
|
|
$('#sidebar-close').addEventListener('click', closeSidebar);
|
|
$('#sidebar-overlay').addEventListener('click', closeSidebar);
|
|
|
|
$('#new-chat-btn').addEventListener('click', function() { newConversation(); });
|
|
$('#new-chat-sidebar').addEventListener('click', function() { newConversation(); closeSidebar(); });
|
|
|
|
$('#settings-btn').addEventListener('click', function() {
|
|
populateSettings();
|
|
showScreen('settings');
|
|
});
|
|
|
|
$('#settings-back').addEventListener('click', function() {
|
|
saveSettings();
|
|
showScreen('chat');
|
|
});
|
|
|
|
$('#settings-temp').addEventListener('input', function() {
|
|
$('#temp-value').textContent = this.value;
|
|
});
|
|
|
|
$('#settings-tokens').addEventListener('input', function() {
|
|
$('#tokens-value').textContent = this.value;
|
|
});
|
|
|
|
$('#settings-token').addEventListener('change', saveSettings);
|
|
$('#settings-url').addEventListener('change', saveSettings);
|
|
$('#settings-model').addEventListener('change', saveSettings);
|
|
$('#settings-websearch').addEventListener('change', saveSettings);
|
|
$('#settings-streaming').addEventListener('change', saveSettings);
|
|
|
|
$('#theme-toggle-header').addEventListener('click', toggleTheme);
|
|
|
|
$('#settings-darkmode').addEventListener('change', function() {
|
|
applyTheme(this.checked ? 'dark' : 'light');
|
|
});
|
|
|
|
$('#terminal-toggle').addEventListener('click', toggleTerminal);
|
|
|
|
$('#export-btn').addEventListener('click', exportConversations);
|
|
|
|
$('#clear-btn').addEventListener('click', function() {
|
|
if (confirm('Clear all conversations? This cannot be undone.')) {
|
|
state.conversations = [];
|
|
state.activeConversationId = null;
|
|
saveState();
|
|
renderConversationList();
|
|
renderMessages();
|
|
updateHeader();
|
|
updateTerminalContent();
|
|
}
|
|
});
|
|
|
|
updateModeSelector();
|
|
updateSendButton();
|
|
applyTheme(state.theme);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|