v1.2.4: Save/Download buttons on code blocks and messages, file path highlighting
This commit is contained in:
@@ -418,7 +418,8 @@ a:hover { text-decoration: underline; }
|
||||
border-bottom: none;
|
||||
}
|
||||
.code-header + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
||||
.copy-btn {
|
||||
.code-header-actions { display: flex; gap: 4px; align-items: center; }
|
||||
.copy-btn, .download-btn {
|
||||
background: var(--accent-dim);
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
@@ -426,8 +427,51 @@ a:hover { text-decoration: underline; }
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.copy-btn:hover { background: var(--accent); color: white; }
|
||||
.copy-btn:hover, .download-btn:hover { background: var(--accent); color: white; }
|
||||
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.msg-action-btn {
|
||||
background: var(--accent-dim);
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.msg-action-btn:hover { background: var(--accent); color: white; }
|
||||
.msg-action-btn.copied { background: var(--success); color: white; }
|
||||
|
||||
.filepath-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
word-break: break-all;
|
||||
}
|
||||
.filepath-badge:hover { background: var(--accent); color: white; }
|
||||
.filepath-badge::before { content: '📁'; font-size: 11px; }
|
||||
|
||||
.thinking-indicator {
|
||||
display: flex;
|
||||
|
||||
@@ -197,13 +197,24 @@
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>About</h3>
|
||||
<p class="about-text">Z.AI Chat v1.2.3</p>
|
||||
<p class="about-text">Z.AI Chat v1.2.4</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.4</span>
|
||||
<span class="changelog-date">2026-05-19</span>
|
||||
<ul>
|
||||
<li>Save button on every code block — downloads code as a real file to your phone</li>
|
||||
<li>Copy & Save buttons on every AI response — one-tap copy or save as .txt</li>
|
||||
<li>File paths in AI responses are now highlighted and tappable (tap to copy)</li>
|
||||
<li>Smart filename detection — auto-names saved files from code content</li>
|
||||
<li>30+ language-to-extension mappings for proper file saving</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<span class="changelog-version">v1.2.3</span>
|
||||
<span class="changelog-date">2026-05-19</span>
|
||||
|
||||
119
www/js/app.js
119
www/js/app.js
@@ -226,6 +226,40 @@
|
||||
return text.replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
var EXT_MAP = {
|
||||
python: 'py', py: 'py', javascript: 'js', js: 'js', typescript: 'ts', ts: 'ts',
|
||||
java: 'java', kotlin: 'kt', kt: 'kt', html: 'html', css: 'css', json: 'json',
|
||||
xml: 'xml', yaml: 'yml', yml: 'yml', markdown: 'md', md: 'md', sql: 'sql',
|
||||
shell: 'sh', bash: 'sh', sh: 'sh', powershell: 'ps1', dockerfile: 'Dockerfile',
|
||||
ruby: 'rb', go: 'go', rust: 'rs', c: 'c', cpp: 'cpp', csharp: 'cs',
|
||||
swift: 'swift', php: 'php', perl: 'pl', scala: 'scala', groovy: 'groovy',
|
||||
gradle: 'gradle', properties: 'properties', toml: 'toml', ini: 'ini',
|
||||
dart: 'dart', lua: 'lua', r: 'r', protobuf: 'proto'
|
||||
};
|
||||
|
||||
function guessFileName(code, lang) {
|
||||
var firstLine = code.trim().split('\n')[0];
|
||||
if (/^(\/|\.\/|\.\.\/|[A-Za-z]:\\|[a-zA-Z0-9_\-]+\.[a-zA-Z]{1,10})/.test(firstLine) &&
|
||||
firstLine.length < 120 && firstLine.split('\n').length === 1 &&
|
||||
/\.\w+$/.test(firstLine)) {
|
||||
return firstLine.replace(/^.*[\/\\]/, '');
|
||||
}
|
||||
var ext = EXT_MAP[lang] || lang || 'txt';
|
||||
return 'code.' + ext;
|
||||
}
|
||||
|
||||
function downloadFile(content, filename) {
|
||||
var blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function() { URL.revokeObjectURL(url); }, 5000);
|
||||
}
|
||||
|
||||
function addCodeHeaders(container) {
|
||||
container.querySelectorAll('pre code').forEach(function(block) {
|
||||
var pre = block.parentElement;
|
||||
@@ -233,7 +267,11 @@
|
||||
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>';
|
||||
var fileName = guessFileName(block.textContent, lang);
|
||||
header.innerHTML = '<span>' + escapeHtml(lang) + '</span>' +
|
||||
'<div class="code-header-actions">' +
|
||||
'<button class="download-btn" data-filename="' + escapeHtml(fileName) + '">Save</button>' +
|
||||
'<button class="copy-btn">Copy</button></div>';
|
||||
pre.parentElement.insertBefore(header, pre);
|
||||
header.querySelector('.copy-btn').addEventListener('click', function() {
|
||||
navigator.clipboard.writeText(block.textContent).then(function() {
|
||||
@@ -241,10 +279,48 @@
|
||||
setTimeout(function() { this.textContent = 'Copy'; }.bind(this), 2000);
|
||||
}.bind(this));
|
||||
});
|
||||
header.querySelector('.download-btn').addEventListener('click', function() {
|
||||
var fn = this.getAttribute('data-filename');
|
||||
var code = block.textContent;
|
||||
if (fn && /\.\w+$/.test(fn)) {
|
||||
var lines = code.split('\n');
|
||||
if (lines[0].trim() === fn || lines[0].trim().endsWith('/' + fn)) {
|
||||
code = lines.slice(1).join('\n');
|
||||
}
|
||||
}
|
||||
downloadFile(code, fn);
|
||||
this.textContent = 'Saved!';
|
||||
setTimeout(function() { this.textContent = 'Save'; }.bind(this), 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function highlightFilePaths(html) {
|
||||
return html.replace(
|
||||
/(^|[\s(>])(\/(?:[\w\-\.]+\/){1,}[\w\-\.]+\.\w{1,10})([\s)<,]|$)/gm,
|
||||
'$1<span class="filepath-badge" tabindex="0">$2</span>$3'
|
||||
);
|
||||
}
|
||||
|
||||
function addFilePathHandlers(container) {
|
||||
container.querySelectorAll('.filepath-badge').forEach(function(badge) {
|
||||
badge.addEventListener('click', function() {
|
||||
var path = this.textContent;
|
||||
navigator.clipboard.writeText(path).then(function() {
|
||||
badge.style.background = 'var(--success)';
|
||||
badge.style.borderColor = 'var(--success)';
|
||||
badge.style.color = 'white';
|
||||
setTimeout(function() {
|
||||
badge.style.background = '';
|
||||
badge.style.borderColor = '';
|
||||
badge.style.color = '';
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
var container = $('#messages');
|
||||
if (!container) return;
|
||||
@@ -270,6 +346,33 @@
|
||||
if (role === 'assistant') {
|
||||
div.innerHTML = renderMarkdown(content);
|
||||
addCodeHeaders(div);
|
||||
var processed = highlightFilePaths(div.innerHTML);
|
||||
if (processed !== div.innerHTML) {
|
||||
div.innerHTML = processed;
|
||||
addFilePathHandlers(div);
|
||||
}
|
||||
var actionBar = document.createElement('div');
|
||||
actionBar.className = 'msg-actions';
|
||||
actionBar.innerHTML =
|
||||
'<button class="msg-action-btn msg-copy-btn">Copy</button>' +
|
||||
'<button class="msg-action-btn msg-save-btn">Save .txt</button>';
|
||||
div.appendChild(actionBar);
|
||||
actionBar.querySelector('.msg-copy-btn').addEventListener('click', function() {
|
||||
var btn = this;
|
||||
navigator.clipboard.writeText(content).then(function() {
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function() { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
|
||||
});
|
||||
});
|
||||
actionBar.querySelector('.msg-save-btn').addEventListener('click', function() {
|
||||
var btn = this;
|
||||
var conv = getConversation();
|
||||
var fn = (conv ? conv.title : 'response').replace(/[^a-zA-Z0-9_\-]/g, '_').substring(0, 40);
|
||||
downloadFile(content, fn + '.txt');
|
||||
btn.textContent = 'Saved!';
|
||||
setTimeout(function() { btn.textContent = 'Save .txt'; }, 2000);
|
||||
});
|
||||
} else {
|
||||
div.textContent = content;
|
||||
}
|
||||
@@ -281,6 +384,20 @@
|
||||
function updateStreamingMessage(div, content) {
|
||||
div.innerHTML = renderMarkdown(content);
|
||||
addCodeHeaders(div);
|
||||
var processed = highlightFilePaths(div.innerHTML);
|
||||
if (processed !== div.innerHTML) {
|
||||
div.innerHTML = processed;
|
||||
addFilePathHandlers(div);
|
||||
}
|
||||
var actions = div.querySelector('.msg-actions');
|
||||
if (!actions) {
|
||||
var actionBar = document.createElement('div');
|
||||
actionBar.className = 'msg-actions';
|
||||
actionBar.innerHTML =
|
||||
'<button class="msg-action-btn msg-copy-btn">Copy</button>' +
|
||||
'<button class="msg-action-btn msg-save-btn">Save .txt</button>';
|
||||
div.appendChild(actionBar);
|
||||
}
|
||||
$('#messages').scrollTop = $('#messages').scrollHeight;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user