This commit is contained in:
kim
2026-04-29 15:39:52 +09:00
parent 3e8974a8eb
commit 5fc7cdb6be
49 changed files with 710 additions and 68 deletions
+101 -19
View File
@@ -4154,6 +4154,76 @@ function toggleSteps(id) {
el.previousElementSibling.textContent = `${visible ? '?' : '?'} ${el.querySelectorAll('.react-step').length} tool step${el.querySelectorAll('.react-step').length !== 1 ? 's' : ''} used`;
}
function renderFileDownloads(steps, processEntries) {
const seenUrls = new Set();
const buttons = [];
const URL_RE = /\/api\/files\/[^\s)"'\]]+/g;
function extractUrls(text) {
const urls = [];
let m;
while ((m = URL_RE.exec(text)) !== null) urls.push(m[0]);
return urls;
}
// Collect /api/files/ URLs from all available sources
const allUrls = [];
// 1. msg.steps (legacy step events)
if (steps && steps.length) {
for (const s of steps) {
const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
allUrls.push(...extractUrls(resultText));
}
}
// 2. msg.processEntries (stored per-message in chatHistory)
if (processEntries && processEntries.length) {
for (const e of processEntries) {
const txt = String(e.content || '');
if (txt.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(txt));
}
}
// 3. Global processLogEntries (current session's live log)
try {
const entries = (typeof processLogEntries !== 'undefined' && Array.isArray(processLogEntries)) ? processLogEntries : [];
for (const e of entries) {
const txt = String(e.content || '');
if (txt.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(txt));
}
} catch {}
// 4. msg.content itself (LLM may include the link in its reply)
if (arguments.length > 2 && arguments[2]) {
const contentText = String(arguments[2]);
if (contentText.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(contentText));
}
for (const rawUrl of allUrls) {
if (seenUrls.has(rawUrl)) continue;
seenUrls.add(rawUrl);
const decoded = decodeURIComponent(rawUrl);
const filename = decoded.split('/').pop() || 'download';
const isPptx = decoded.toLowerCase().indexOf('.pptx') !== -1;
if (isPptx) {
const previewId = 'pptx-chat-' + Math.random().toString(36).substr(2, 9);
buttons.push(`
<div class="pptx-file-row" style="margin:6px 0">
<a href="${escHtml(rawUrl)}" onclick="downloadFile(event, this)" class="download-btn" title="Download ${escHtml(filename)}">
<span class="download-icon">&#11015;</span> ${escHtml(filename)}</a>
<button class="pptx-preview-btn" onclick="togglePptxPreview('${escHtml(rawUrl)}', '${previewId}', this)" title="Preview slides">&#9664; Preview</button>
<div id="${previewId}" class="pptx-preview-strip" style="display:none"></div>
</div>`);
} else {
buttons.push(`
<a href="${escHtml(rawUrl)}" onclick="downloadFile(event, this)" class="download-btn" title="Download ${escHtml(filename)}">
<span class="download-icon">&#11015;</span> ${escHtml(filename)}</a>`);
}
}
return buttons.length ? `<div style="margin-top:8px">${buttons.join(' ')}</div>` : '';
}
function renderAssistantContent(content) {
const text = String(content || '');
const hasStaged = /(^|\n)(Initial chat|Execution result|Final chat):\s*/i.test(text);
@@ -4264,6 +4334,7 @@ function renderChatMessages() {
<div class="msg-role">${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}</div>
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.content)}</div>`}
${msg.role === 'ai' ? renderArtifacts(msg.artifacts) : ''}
${msg.role === 'ai' ? renderFileDownloads(msg.steps, msg.processEntries, msg.content) : ''}
${msg.role === 'ai' && msg.processEntries && msg.processEntries.length ? renderProcessPill(msg.processEntries) : ''}
${msg.role === 'ai' ? renderThinkBlock(msg.thinking) : ''}
${msg.role === 'ai' ? renderReactSteps(msg.steps) : ''}
@@ -4335,7 +4406,9 @@ function formatProcessLines(entries) {
const rawType = String(e.type || 'info');
const typeLabel = escHtml(rawType).toUpperCase();
const typeColor = TYPE_COLORS[rawType] || 'var(--muted)';
const content = escHtml(String(e.content || ''));
const rawContent = String(e.content || '');
// Render markdown for result entries so download links show as clickable buttons
const content = rawType === 'result' ? renderMarkdown(rawContent) : escHtml(rawContent);
return `<div style="margin-bottom:4px"><span style="color:var(--muted)">[${escHtml(String(e.ts || ''))}]</span> <span style="color:${typeColor};font-weight:600">${typeLabel}</span> ${content}</div>`;
}).join('');
}
@@ -7419,6 +7492,27 @@ function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _renderLinkButton(text, url) {
var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3'];
var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); })
|| fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; });
var isPptx = decodeURIComponent(url).toLowerCase().indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx');
if (isPptx) {
var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9);
return '<div class="pptx-file-row">'
+ '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>'
+ ' <button class="pptx-preview-btn" onclick="togglePptxPreview(\'' + escHtml(url) + '\', \'' + previewId + '\', this)" title="Preview slides">&#9664; Preview</button>'
+ '<div id="' + previewId + '" class="pptx-preview-strip" style="display:none"></div>'
+ '</div>';
}
if (isDownload) {
return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>';
}
return '<a href="' + url + '" target="_blank" rel="noopener">' + text + '</a>';
}
function renderMarkdown(text) {
if (!text) return '';
@@ -7453,24 +7547,12 @@ function renderMarkdown(text) {
// Links: [text](url) — render download links for file types as buttons, PPTX gets preview
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, text, url) {
var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3'];
var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); })
|| fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; });
var isPptx = decodeURIComponent(url).toLowerCase().indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx');
if (isPptx) {
var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9);
return '<div class="pptx-file-row">'
+ '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>'
+ ' <button class="pptx-preview-btn" onclick="togglePptxPreview(\'' + escHtml(url) + '\', \'' + previewId + '\', this)" title="Preview slides">&#9664; Preview</button>'
+ '<div id="' + previewId + '" class="pptx-preview-strip" style="display:none"></div>'
+ '</div>';
}
if (isDownload) {
return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>';
}
return '<a href="' + url + '" target="_blank" rel="noopener">' + text + '</a>';
return _renderLinkButton(text, url);
});
// Bare /api/files/... URLs — auto-convert to download buttons even without markdown syntax
html = html.replace(/(\s|^)(\/api\/files\/[^\s<"]+)/g, function(_, space, url) {
return space + _renderLinkButton(decodeURIComponent(url.split('/').pop() || 'download'), url);
});
// Bold