v1.2.2 - Fix network error on background, auto-retry streaming with reconnect
This commit is contained in:
@@ -811,6 +811,19 @@ a:hover { text-decoration: underline; }
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 24px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.retry-btn:hover { background: var(--accent-hover); transform: scale(1.05); }
|
||||
|
||||
.terminal-toggle-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
|
||||
@@ -197,13 +197,26 @@
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>About</h3>
|
||||
<p class="about-text">Z.AI Chat v1.2.1</p>
|
||||
<p class="about-text">Z.AI Chat v1.2.2</p>
|
||||
<p class="about-text">Built with Z.AI SDK & GLM-5.1</p>
|
||||
<p class="about-text">Compatible with Android 15/16</p>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>Changelog</h3>
|
||||
<ul class="changelog-list">
|
||||
<li>
|
||||
<span class="changelog-version">v1.2.2</span>
|
||||
<span class="changelog-date">2026-05-19</span>
|
||||
<ul>
|
||||
<li>Fixed: network error on app backgrounding — now auto-retries with reconnect</li>
|
||||
<li>Streaming resumes from last saved token after reconnection</li>
|
||||
<li>Exponential backoff retry (3 attempts) for network interruptions</li>
|
||||
<li>Retry button shown for failed requests due to connectivity</li>
|
||||
<li>Offline/online detection with status banner</li>
|
||||
<li>Visibility change handler saves state on app background</li>
|
||||
<li>Partial response preserved and restorable on reconnect</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<span class="changelog-version">v1.2.1</span>
|
||||
<span class="changelog-date">2026-05-19</span>
|
||||
|
||||
229
www/js/app.js
229
www/js/app.js
@@ -337,6 +337,9 @@
|
||||
updateSendButton();
|
||||
showThinking();
|
||||
|
||||
var requestBody = null;
|
||||
var responseDiv = null;
|
||||
|
||||
try {
|
||||
var systemPrompt = MODE_PROMPTS[state.currentMode] || MODE_PROMPTS.chat;
|
||||
var apiMessages = [{ role: 'system', content: systemPrompt }];
|
||||
@@ -346,7 +349,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
var requestBody = {
|
||||
requestBody = {
|
||||
model: state.model,
|
||||
messages: apiMessages,
|
||||
temperature: state.temperature,
|
||||
@@ -362,13 +365,13 @@
|
||||
}
|
||||
|
||||
removeThinking();
|
||||
var responseDiv = appendMessage('assistant', '');
|
||||
responseDiv = appendMessage('assistant', '');
|
||||
state.streamingResponseDiv = responseDiv;
|
||||
|
||||
if (state.streaming) {
|
||||
await streamResponse(requestBody, responseDiv, conv);
|
||||
await streamResponseWithRetry(requestBody, responseDiv, conv);
|
||||
} else {
|
||||
var result = await apiRequest(requestBody);
|
||||
var result = await apiRequestWithRetry(requestBody);
|
||||
var content = result.choices[0].message.content;
|
||||
updateStreamingMessage(responseDiv, content);
|
||||
state.streamingContent = content;
|
||||
@@ -377,9 +380,11 @@
|
||||
} catch(err) {
|
||||
removeThinking();
|
||||
if (err.name !== 'AbortError') {
|
||||
appendMessage('system', 'Error: ' + (err.message || 'Request failed'));
|
||||
}
|
||||
if (state.streamingContent) {
|
||||
if (state.streamingContent) {
|
||||
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false });
|
||||
}
|
||||
var retryDiv = appendRetryMessage(err, requestBody, conv);
|
||||
} else if (state.streamingContent) {
|
||||
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false });
|
||||
}
|
||||
} finally {
|
||||
@@ -393,28 +398,97 @@
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
function isNetworkError(err) {
|
||||
if (!err) return false;
|
||||
var msg = (err.message || '').toLowerCase();
|
||||
var name = (err.name || '').toLowerCase();
|
||||
return name === 'typeerror' || name === 'networkerror' ||
|
||||
msg.indexOf('failed to fetch') >= 0 ||
|
||||
msg.indexOf('network') >= 0 ||
|
||||
msg.indexOf('load failed') >= 0 ||
|
||||
msg.indexOf('connection') >= 0 ||
|
||||
msg.indexOf('net::') >= 0 ||
|
||||
msg.indexOf('interrupted') >= 0;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(function(r) { setTimeout(r, ms); });
|
||||
}
|
||||
|
||||
async function apiRequestWithRetry(body, maxRetries) {
|
||||
maxRetries = maxRetries || 3;
|
||||
var lastErr;
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await apiRequest(body);
|
||||
} catch(err) {
|
||||
lastErr = err;
|
||||
if (!isNetworkError(err) || attempt >= maxRetries - 1) throw err;
|
||||
var delay = 1000 * Math.pow(2, attempt);
|
||||
appendMessage('system', 'Connection lost. Retrying in ' + (delay / 1000) + 's... (attempt ' + (attempt + 2) + '/' + maxRetries + ')');
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
return await resp.json();
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
var _streamAutoSaveCounter = 0;
|
||||
|
||||
async function streamResponse(body, responseDiv, conv) {
|
||||
async function streamResponseWithRetry(body, responseDiv, conv, maxRetries) {
|
||||
maxRetries = maxRetries || 3;
|
||||
var lastErr;
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
await streamResponse(body, responseDiv, conv, attempt > 0);
|
||||
return;
|
||||
} catch(err) {
|
||||
lastErr = err;
|
||||
if (err.name === 'AbortError') throw err;
|
||||
if (!isNetworkError(err)) throw err;
|
||||
if (attempt >= maxRetries - 1) throw err;
|
||||
|
||||
if (state.streamingContent) {
|
||||
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: true });
|
||||
saveState();
|
||||
}
|
||||
|
||||
var delay = 1500 * Math.pow(2, attempt);
|
||||
var retryNotice = document.createElement('div');
|
||||
retryNotice.className = 'message system';
|
||||
retryNotice.innerHTML = '<div style="display:flex;align-items:center;gap:6px;justify-content:center">' +
|
||||
'<div class="thinking-dots"><span></span><span></span><span></span></div>' +
|
||||
' Reconnecting... (attempt ' + (attempt + 2) + '/' + maxRetries + ')</div>';
|
||||
$('#messages').appendChild(retryNotice);
|
||||
$('#messages').scrollTop = $('#messages').scrollHeight;
|
||||
|
||||
await sleep(delay);
|
||||
if (retryNotice.parentElement) retryNotice.remove();
|
||||
|
||||
var lastAssistant = '';
|
||||
for (var mi = conv.messages.length - 1; mi >= 0; mi--) {
|
||||
if (conv.messages[mi].role === 'assistant' && !conv.messages[mi]._streaming) {
|
||||
lastAssistant = conv.messages[mi].content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
body.messages = body.messages.filter(function(m) { return m.role !== 'assistant'; });
|
||||
if (state.streamingContent) {
|
||||
body.messages.push({ role: 'assistant', content: state.streamingContent });
|
||||
}
|
||||
body.stream = true;
|
||||
|
||||
if (responseDiv && responseDiv.parentElement) {
|
||||
var currentText = state.streamingContent || lastAssistant;
|
||||
updateStreamingMessage(responseDiv, currentText + '\n\n*--- connection interrupted, resuming ---*\n');
|
||||
state.streamingContent = currentText;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
async function streamResponse(body, responseDiv, conv, isRetry) {
|
||||
state.abortController = new AbortController();
|
||||
body.stream = true;
|
||||
|
||||
@@ -496,7 +570,111 @@
|
||||
flushStreamingToConversation();
|
||||
}
|
||||
|
||||
function updateSendButton() {
|
||||
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();
|
||||
}
|
||||
|
||||
function appendRetryMessage(err, requestBody, conv) {
|
||||
var container = $('#messages');
|
||||
var div = document.createElement('div');
|
||||
div.className = 'message system';
|
||||
var isNet = isNetworkError(err);
|
||||
div.innerHTML = '<div style="text-align:center">' +
|
||||
'<div style="color:var(--danger);margin-bottom:8px">' +
|
||||
(isNet ? 'Connection interrupted' : escapeHtml(err.message || 'Request failed')) +
|
||||
'</div>' +
|
||||
(isNet ? '<button class="retry-btn" id="retry-btn">Retry</button>' : '') +
|
||||
'</div>';
|
||||
container.appendChild(div);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
if (isNet) {
|
||||
setTimeout(function() {
|
||||
var btn = $('#retry-btn');
|
||||
if (btn) btn.addEventListener('click', function() {
|
||||
if (div.parentElement) div.remove();
|
||||
retryLastRequest(requestBody, conv);
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
async function retryLastRequest(requestBody, conv) {
|
||||
if (!requestBody || !conv) return;
|
||||
state.isGenerating = true;
|
||||
state.streamingConvId = conv.id;
|
||||
updateSendButton();
|
||||
|
||||
var responseDiv = appendMessage('assistant', '');
|
||||
state.streamingResponseDiv = responseDiv;
|
||||
|
||||
try {
|
||||
if (state.streaming) {
|
||||
requestBody.stream = true;
|
||||
await streamResponseWithRetry(requestBody, responseDiv, conv);
|
||||
} else {
|
||||
var result = await apiRequestWithRetry(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 (state.streamingContent) {
|
||||
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false });
|
||||
}
|
||||
appendRetryMessage(err, requestBody, conv);
|
||||
} finally {
|
||||
state.isGenerating = false;
|
||||
state.abortController = null;
|
||||
state.streamingConvId = null;
|
||||
state.streamingResponseDiv = null;
|
||||
updateSendButton();
|
||||
saveState();
|
||||
updateTerminalContent();
|
||||
}
|
||||
}
|
||||
|
||||
function setupVisibilityHandler() {
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushStreamingToConversation();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('online', function() {
|
||||
var msg = $('#offline-msg');
|
||||
if (msg) msg.remove();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', function() {
|
||||
var container = $('#messages');
|
||||
if (container && !$('#offline-msg')) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'message system';
|
||||
div.id = 'offline-msg';
|
||||
div.innerHTML = '<div style="color:var(--warning)">You are offline. Messages will be saved and sent when connection is restored.</div>';
|
||||
container.appendChild(div);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
var input = $('#message-input');
|
||||
var sendBtn = $('#send-btn');
|
||||
var stopBtn = $('#stop-btn');
|
||||
@@ -906,6 +1084,7 @@
|
||||
updateModeSelector();
|
||||
updateSendButton();
|
||||
applyTheme(state.theme);
|
||||
setupVisibilityHandler();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
Reference in New Issue
Block a user