From 9f7e3b03ae5d767543e4a192c6d7a5e2dd4f4eb4 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 19 May 2026 15:28:15 +0400 Subject: [PATCH] v1.2.1 - Fix session data loss on switch, add terminal panel for coding/agentic modes --- README.md | 10 ++ android/app/build.gradle | 4 +- package.json | 2 +- www/css/styles.css | 149 +++++++++++++++++++++ www/index.html | 29 ++++- www/js/app.js | 274 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 456 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ec22812..e7742b9 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,16 @@ data: [DONE] ## Changelog +### v1.2.1 (2026-05-19) +- Fixed: messages lost when switching conversations during streaming generation +- Streaming responses auto-save every 20 tokens to prevent data loss +- Partial responses preserved when switching sessions mid-generation +- Added in-app terminal panel for Coding and Agentic modes +- Terminal parses code blocks, file operations, and tool calls from AI responses +- Terminal toggle button with collapsible panel (persists state) +- Conversation list now shows message count per session +- Improved conversation switch safety with flush-before-switch pattern + ### v1.2.0 (2026-05-19) - Added light mode / dark mode toggle - Theme persists across sessions via localStorage diff --git a/android/app/build.gradle b/android/app/build.gradle index 2d2c08d..59ed888 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "ai.z.chat" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 2 - versionName "1.2.0" + versionCode 3 + versionName "1.2.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' diff --git a/package.json b/package.json index 7869faa..608d8eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zai-chat", - "version": "1.2.0", + "version": "1.2.1", "description": "Z.AI Chat - Full stack AI chat powered by GLM Coding Plan", "main": "index.js", "scripts": { diff --git a/www/css/styles.css b/www/css/styles.css index b0da3ca..b965ea0 100644 --- a/www/css/styles.css +++ b/www/css/styles.css @@ -693,6 +693,155 @@ a:hover { text-decoration: underline; } font-size: 12px; } +.terminal-panel { + display: none; + flex-direction: column; + background: var(--bg-code); + border-top: 1px solid var(--border); + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + flex-shrink: 0; +} +.terminal-panel.open { + max-height: 45vh; +} +.terminal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.terminal-title { + font-size: 12px; + font-weight: 700; + color: var(--success); + text-transform: uppercase; + letter-spacing: 1px; + font-family: 'Fira Code', 'JetBrains Mono', monospace; +} +.terminal-info { + font-size: 11px; + color: var(--text-muted); + font-family: monospace; +} +.terminal-body { + overflow-y: auto; + padding: 8px; + font-family: 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace; + font-size: 12px; + line-height: 1.5; + flex: 1; + min-height: 60px; + max-height: calc(45vh - 40px); +} +.term-empty { + color: var(--text-muted); + text-align: center; + padding: 20px; + font-size: 12px; +} +.term-entry { + margin-bottom: 8px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border); +} +.term-tool { + background: var(--bg-secondary); +} +.term-tool-header { + padding: 6px 10px; + font-size: 11px; + font-weight: 700; + font-family: monospace; + background: var(--bg-tertiary); +} +.term-action { + font-weight: 800; + letter-spacing: 0.5px; +} +.term-target { + color: var(--text-secondary); + font-weight: 400; + margin-left: 4px; + word-break: break-all; +} +.term-code-block { + background: var(--bg-secondary); +} +.term-file-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 10px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); +} +.term-lang { + font-size: 11px; + color: var(--accent); + font-weight: 600; +} +.term-copy-btn { + background: var(--accent-dim); + border: none; + color: var(--accent); + padding: 2px 8px; + border-radius: 3px; + font-size: 10px; + cursor: pointer; + font-family: inherit; +} +.term-copy-btn:hover { background: var(--accent); color: white; } +.term-code { + padding: 8px 10px; + margin: 0; + font-size: 11px; + line-height: 1.4; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-all; + overflow-x: auto; + max-height: 200px; + overflow-y: auto; + background: transparent; +} + +.terminal-toggle-btn { + display: none; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + padding: 8px; + background: var(--bg-tertiary); + border: none; + border-top: 1px solid var(--border); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + flex-shrink: 0; +} +.terminal-toggle-btn:hover { + background: var(--accent-dim); + color: var(--accent); +} +.terminal-icon { + font-size: 10px; +} +.terminal-label { + font-family: 'Fira Code', monospace; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 11px; +} + /* Responsive */ @media (max-width: 480px) { .message { max-width: 92%; } diff --git a/www/index.html b/www/index.html index 029872f..923e685 100644 --- a/www/index.html +++ b/www/index.html @@ -96,6 +96,18 @@
+
+
+ ■ Terminal + +
+
+
+ +
@@ -185,13 +197,28 @@

About

-

Z.AI Chat v1.2.0

+

Z.AI Chat v1.2.1

Built with Z.AI SDK & GLM-5.1

Compatible with Android 15/16

Changelog

    +
  • + v1.2.1 + 2026-05-19 +
      +
    • Fixed: messages lost when switching conversations during streaming
    • +
    • Streaming responses now auto-save every 20 tokens to prevent data loss
    • +
    • Partial responses preserved when switching sessions mid-generation
    • +
    • Added in-app terminal panel for Coding and Agentic modes
    • +
    • Terminal parses code blocks, file ops, and tool calls from AI responses
    • +
    • Terminal toggle button with collapsible panel
    • +
    • Terminal state persists across sessions
    • +
    • Conversation list now shows message count
    • +
    • Improved conversation switch safety with flush-before-switch
    • +
    +
  • v1.2.0 2026-05-19 diff --git a/www/js/app.js b/www/js/app.js index 1bc99ed..ee72c6e 100644 --- a/www/js/app.js +++ b/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 = '' + escapeHtml(conv.title) + '' + + var msgCount = conv.messages.length; + div.innerHTML = '' + escapeHtml(conv.title) + + (msgCount > 0 ? ' (' + msgCount + ')' : '') + + '' + ''; 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 = '
    '; + html += '
    [' + icon + '] ' + escapeHtml(entry.action) + ''; + if (entry.target) html += ' ' + escapeHtml(entry.target) + ''; + html += '
    '; + if (entry.body) { + html += '
    ' + escapeHtml(entry.body.substring(0, 2000)) + (entry.body.length > 2000 ? '\n... (truncated)' : '') + '
    '; + } + html += '
    '; + 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 = '
    '; + html += '
    ' + label + ''; + html += '
    '; + html += '
    ' + escapeHtml(displayCode.substring(0, 3000)) + (displayCode.length > 3000 ? '\n... (truncated)' : '') + '
    '; + html += '
    '; + 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 = '
    No code output yet. Use Coding or Agentic mode to generate code.
    '; + return; + } + + var content = state.streamingContent || (lastAssistant ? lastAssistant.content : ''); + var entries = parseTerminalEntries(content); + + if (entries.length === 0) { + termBody.innerHTML = '
    No structured code blocks or tool calls detected in response.
    '; + 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(); } });