v1.2.0 - Z.AI Chat for Android with dark/light themes, 4 chat modes, streaming SSE, coding plan endpoint

This commit is contained in:
admin
2026-05-19 15:17:22 +04:00
Unverified
commit d62a850ac5
67 changed files with 5593 additions and 0 deletions

658
www/js/app.js Normal file
View File

@@ -0,0 +1,658 @@
(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 MODE_EMOJIS = { chat: '\u{1F4AC}', coding: '\u{1F4BB}', brainstorm: '\u{1F4A1}', agentic: '\u{1F916}' };
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
};
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';
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 + '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() {
if (!state.activeConversationId) return null;
return state.conversations.find(function(c) { return c.id === state.activeConversationId; });
}
function newConversation() {
var conv = {
id: genId(),
title: 'New Chat',
mode: state.currentMode,
messages: [],
createdAt: Date.now()
};
state.conversations.unshift(conv);
state.activeConversationId = conv.id;
saveState();
renderConversationList();
renderMessages();
updateHeader();
}
function switchConversation(id) {
state.activeConversationId = id;
var conv = getConversation();
if (conv) {
state.currentMode = conv.mode || 'chat';
updateModeSelector();
}
saveState();
renderConversationList();
renderMessages();
updateHeader();
closeSidebar();
}
function deleteConversation(id) {
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();
}
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' : '');
div.innerHTML = '<span class="conv-title">' + escapeHtml(conv.title) + '</span>' +
'<button class="conv-delete" data-id="' + conv.id + '">&times;</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, '&lt;').replace(/>/g, '&gt;').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;
}
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 });
input.value = '';
autoResize(input);
updateSendButton();
appendMessage('user', text);
state.isGenerating = true;
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') {
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', '');
if (state.streaming) {
await streamResponse(requestBody, responseDiv, conv);
} else {
var result = await apiRequest(requestBody);
var content = result.choices[0].message.content;
updateStreamingMessage(responseDiv, content);
conv.messages.push({ role: 'assistant', content: content });
}
} catch(err) {
removeThinking();
if (err.name !== 'AbortError') {
appendMessage('system', 'Error: ' + (err.message || 'Request failed'));
}
} finally {
state.isGenerating = false;
state.abortController = null;
updateSendButton();
saveState();
}
}
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();
}
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 = '';
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;
updateStreamingMessage(responseDiv, fullContent);
}
} catch(e) {}
}
}
conv.messages.push({ role: 'assistant', content: fullContent });
}
function stopGeneration() {
if (state.abortController) {
state.abortController.abort();
}
}
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' ? '&#9788;' : '&#9790;';
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');
}
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();
$('#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();
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');
});
$('#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();
}
});
updateModeSelector();
updateSendButton();
applyTheme(state.theme);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();