This commit is contained in:
kim
2026-04-30 18:04:21 +09:00
parent 7b34afbd38
commit 7177f684b7
46 changed files with 932 additions and 19 deletions
+340 -9
View File
@@ -2302,9 +2302,11 @@
</div>
<div class="queued-prompts-list" id="queued-prompts-list"></div>
</div>
<div class="chat-input-row">
<input type="file" id="image-upload" accept="image/*" style="display:none" onchange="handleImageUpload(this)" />
<button class="send-btn" onclick="document.getElementById('image-upload').click()" title="이미지 첨부" style="font-size:18px;width:44px;height:44px">&#128206;</button>
<!-- 첨부 파일 카드 영역 (전송 전 표시) -->
<div id="staged-attachments" style="display:none;flex-wrap:wrap;gap:8px;padding:8px 12px 0;border-top:1px solid var(--line)"></div>
<div class="chat-input-row">
<input type="file" id="file-upload" accept="image/*,.pdf,.txt,.md,.csv,.xlsx,.xls,.docx,.pptx" style="display:none" onchange="handleFileUpload(this)" />
<button class="send-btn" onclick="document.getElementById('file-upload').click()" title="파일 첨부 (이미지·PDF·Excel·txt 등)" style="font-size:18px;width:44px;height:44px" aria-label="파일 첨부">📎</button>
<textarea
id="chat-input"
placeholder="메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)"
@@ -4418,7 +4420,7 @@ function renderChatMessages() {
<div class="msg-avatar">${msg.role === 'user' ? 'U' : '<img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px">'}</div>
<div class="msg-body">
<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' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.display !== undefined ? msg.display : 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) : ''}
@@ -4702,7 +4704,7 @@ async function sendChat(queuedMessage = null) {
const sendBtn = document.getElementById('send-btn');
const raw = typeof queuedMessage === 'string' ? queuedMessage : input.value;
const message = String(raw || '').trim();
if (!message) return;
if (!message && stagedAttachments.length === 0) return;
if (isThinking) {
if (typeof queuedMessage === 'string') return;
if (queuedPrompts.length >= MAX_QUEUED_PROMPTS) {
@@ -4717,7 +4719,31 @@ async function sendChat(queuedMessage = null) {
return;
}
chatHistory.push({ role: 'user', content: message });
// 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만
let fullMessage = message;
let displayMessage = message;
if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') {
const attachParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함
.map(function(a) {
return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')');
});
fullMessage = attachParts.join('\n\n') + (message ? '\n\n' + message : '');
const compactParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; })
.map(function(a) {
// [파일명](url) → renderMarkdown이 PDF/파일 버튼으로 렌더링
var line = a.url ? '[' + a.name + '](' + a.url + ')' : '📎 **' + a.name + '**';
// PDF에 txt 저장본이 있으면 txt 다운로드 링크도 추가
if (a.type === 'pdf' && a.txtUrl) {
line += ' [📝 ' + a.txtName + '](' + a.txtUrl + ')';
}
return line;
});
displayMessage = compactParts.join('\n') + (message ? '\n\n' + message : '');
clearStagedAttachments();
}
chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage });
persistActiveChat();
if (typeof queuedMessage !== 'string') {
input.value = '';
@@ -4784,7 +4810,7 @@ async function sendChat(queuedMessage = null) {
'Accept': 'text/event-stream',
},
signal: currentAbortController.signal,
body: JSON.stringify({ message, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined })
body: JSON.stringify({ message: fullMessage, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined })
});
if (!res.ok) {
@@ -7599,16 +7625,32 @@ 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');
var decodedUrl = decodeURIComponent(url).toLowerCase();
var isPptx = decodedUrl.indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx');
var isPdf = decodedUrl.indexOf('.pdf') !== -1 || text.toLowerCase().endsWith('.pdf');
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>'
+ ' <button class="pptx-preview-btn" onclick="togglePptxPreview(\'' + escHtml(url) + '\', \'' + previewId + '\', this)" title="미리보기">&#9664; 미리보기</button>'
+ '<div id="' + previewId + '" class="pptx-preview-strip" style="display:none"></div>'
+ '</div>';
}
if (isPdf) {
var pdfId = 'pdf-embed-' + Math.random().toString(36).substr(2, 9);
return '<div class="pdf-file-row" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
+ '<a href="' + url + '" target="_blank" rel="noopener" class="download-btn" title="새 탭에서 열기">'
+ '<span style="font-size:14px">📄</span> ' + text + '</a>'
+ '<button class="pptx-preview-btn" onclick="togglePdfEmbed(\'' + escHtml(url) + '\',\'' + pdfId + '\',this)">&#9664; 미리보기</button>'
+ '<a href="' + url + '" onclick="downloadFile(event,this)" class="pptx-preview-btn" style="text-decoration:none">&#11015; 저장</a>'
+ '</div>'
+ '<div id="' + pdfId + '" style="display:none">'
+ '<iframe style="width:100%;height:600px;border:1px solid var(--line);border-radius:8px" allowfullscreen></iframe>'
+ '</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>';
@@ -7616,6 +7658,20 @@ function _renderLinkButton(text, url) {
return '<a href="' + url + '" target="_blank" rel="noopener">' + text + '</a>';
}
function togglePdfEmbed(url, containerId, btn) {
var container = document.getElementById(containerId);
if (!container) return;
var iframe = container.querySelector('iframe');
if (container.style.display === 'none') {
container.style.display = 'block';
if (iframe) iframe.src = url; // src=""이면 현재페이지로 해석 → 항상 명시 설정
btn.innerHTML = '&#9650; 닫기';
} else {
container.style.display = 'none';
btn.innerHTML = '&#9664; 미리보기';
}
}
function renderMarkdown(text) {
if (!text) return '';
@@ -7803,6 +7859,281 @@ function handleImageUpload(input) {
.catch(function(e) { alert('Upload error: ' + e.message); });
}
// ── 파일 첨부 (이미지·PDF·Excel·txt·csv 등) ─────────────────────────────────
function handleFileUpload(input) {
var file = input.files && input.files[0];
if (!file) return;
input.value = '';
var name = file.name.toLowerCase();
// 이미지
if (file.type.startsWith('image/')) {
var imgForm = new FormData();
imgForm.append('image', file);
fetch('/api/upload/image', { method: 'POST', credentials: 'same-origin', body: imgForm })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
insertIntoChatInput('![' + file.name + '](/api/files/' + data.path + ')');
} else { alert(data.error || '이미지 업로드 실패'); }
})
.catch(function(e) { alert('업로드 오류: ' + e.message); });
// txt / md
} else if (/\.(txt|md)$/.test(name)) {
var r1 = new FileReader();
r1.onload = function(e) {
var ext = name.split('.').pop();
stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```' + ext + '\n' + e.target.result + '\n```', 'text');
};
r1.readAsText(file, 'UTF-8');
// CSV
} else if (/\.csv$/.test(name)) {
var r2 = new FileReader();
r2.onload = function(e) {
stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```csv\n' + e.target.result + '\n```', 'csv');
};
r2.readAsText(file, 'UTF-8');
// Excel
} else if (/\.(xlsx|xls)$/.test(name)) {
loadSheetJS(function() {
var r3 = new FileReader();
r3.onload = function(e) {
try {
var wb = XLSX.read(e.target.result, { type: 'array' });
var result = '**[파일: ' + file.name + ']**\n\n';
wb.SheetNames.forEach(function(sn) {
var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 });
result += '### ' + sn + '\n';
rows.forEach(function(row) { result += '| ' + row.join(' | ') + ' |\n'; });
result += '\n';
});
stageAttachment(file.name, null, result, 'excel');
} catch(err) { alert('Excel 파싱 오류: ' + err.message); }
};
r3.readAsArrayBuffer(file);
});
// PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장
} else if (/\.pdf$/.test(name)) {
var pdfFile = file;
var pdfForm = new FormData();
pdfForm.append('file', pdfFile);
var uploadPromise = fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: pdfForm })
.then(function(r) { return r.json(); });
var extractPromise = new Promise(function(resolve) {
loadPdfJs(function() {
var fr = new FileReader();
fr.onload = function(e) {
pdfjsLib.getDocument({ data: new Uint8Array(e.target.result) }).promise
.then(function(pdf) {
var pages = [];
for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i));
Promise.all(pages).then(function(ps) {
return Promise.all(ps.map(function(p) {
return p.getTextContent().then(function(tc) {
return tc.items.map(function(it) { return it.str; }).join(' ');
});
}));
}).then(function(texts) {
resolve({ text: texts.join('\n\n'), pages: pdf.numPages });
}).catch(function() { resolve({ text: '', pages: 0 }); });
}).catch(function() { resolve({ text: '', pages: 0 }); });
};
fr.readAsArrayBuffer(pdfFile);
});
});
Promise.all([uploadPromise, extractPromise]).then(function(res) {
var upData = res[0], extracted = res[1];
var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null;
var trimmed = (extracted.text || '').trim().slice(0, 8000);
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n';
if (trimmed) {
content += '**[PDF 내용 — ' + extracted.pages + '페이지]**\n\n' + trimmed;
if ((extracted.text || '').trim().length > 8000) content += '\n\n*(앞 8000자만 포함)*';
// PDF 텍스트를 .txt 파일로 자동 저장 → PDF 카드에 txt 버튼 추가
var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt');
var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' });
var txtForm = new FormData();
txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' }));
// PDF 카드 먼저 추가 (txt 저장 대기 없이)
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: txtForm })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.path) {
var txtUrl = '/api/files/' + d.path;
addProcessEntry('info', '📝 텍스트 저장: ' + txtName);
// 이미 스테이징된 PDF 카드에 txt 버튼 주입
addTxtButtonToPdfCard(pdfFile.name, txtName, txtUrl);
}
}).catch(function() {});
} else {
content += '*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
}
}).catch(function(e) { alert('PDF 처리 오류: ' + e.message); });
// 기타 (docx/pptx 등)
} else {
var otherForm = new FormData();
otherForm.append('file', file);
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: otherForm })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
insertIntoChatInput('[' + file.name + '](/api/files/' + data.path + ')');
} else { alert(data.error || '파일 업로드 실패'); }
})
.catch(function(e) { alert('업로드 오류: ' + e.message); });
}
}
function insertIntoChatInput(text) {
var el = document.getElementById('chat-input');
el.value = el.value ? el.value + '\n' + text : text;
el.focus();
el.dispatchEvent(new Event('input'));
}
// ── 첨부 파일 스테이징 ──────────────────────────────────────────────────────
var stagedAttachments = [];
function stageAttachment(name, url, content, type) {
var id = 'att-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
stagedAttachments.push({ id: id, name: name, url: url, content: content, type: type });
renderStagedAttachments();
}
function removeStagedAttachment(id) {
stagedAttachments = stagedAttachments.filter(function(a) { return a.id !== id; });
renderStagedAttachments();
}
function clearStagedAttachments() {
stagedAttachments = [];
renderStagedAttachments();
}
function renderStagedAttachments() {
var container = document.getElementById('staged-attachments');
if (!container) return;
if (!stagedAttachments.length) { container.style.display = 'none'; container.innerHTML = ''; return; }
container.style.display = 'flex';
container.innerHTML = stagedAttachments.map(function(a) {
var icon = a.type === 'pdf' ? '📄' : a.type === 'excel' ? '📊' : a.type === 'csv' ? '📋' : '📝';
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px';
// PDF 카드
if (a.type === 'pdf') {
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>📄</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
+ (a.url ? '<button onclick="toggleStagedPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>' : '')
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ '<div id="pdf-preview-' + a.id + '" style="display:none"><iframe data-src="' + escHtml(a.url || '') + '" style="width:100%;height:400px;border:1px solid var(--line);border-radius:6px"></iframe></div>'
+ '</div>';
}
// TXT 자동저장 카드 (미리보기 + 다운로드)
if (a.type === 'txt-auto') {
var preview = escHtml((a.content || '').slice(0, 300)) + ((a.content || '').length > 300 ? '...' : '');
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px dashed var(--brand);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>📝</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
+ '<button onclick="toggleStagedTxtPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>'
+ (a.url ? '<a href="' + escHtml(a.url) + '" download="' + escHtml(a.name) + '" style="' + btnStyle + ';text-decoration:none;display:inline-block" title="다운로드">⬇ 저장</a>' : '')
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ '<div id="txt-preview-' + a.id + '" style="display:none;max-height:200px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text)">' + preview + '</div>'
+ '</div>';
}
// 기타 카드 (text, csv, excel)
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>' + icon + '</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ '</div>';
}).join('');
}
function toggleStagedPdfPreview(id) {
var el = document.getElementById('pdf-preview-' + id);
if (!el) return;
var att = stagedAttachments.find(function(a) { return a.id === id; });
if (el.style.display === 'none') {
el.style.display = 'block';
var iframe = el.querySelector('iframe');
if (iframe && att && att.url) iframe.src = att.url; // 항상 명시 설정
} else {
el.style.display = 'none';
}
}
function toggleStagedTxtPreview(id) {
var el = document.getElementById('txt-preview-' + id);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
// PDF 카드에 txt 저장 버튼 동적 삽입 (txt 업로드 완료 후 호출)
function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
// stagedAttachments에서 해당 PDF 찾기
var att = stagedAttachments.find(function(a) { return a.name === pdfName && a.type === 'pdf'; });
if (!att) return;
// txt 정보를 attachment에 저장 (전송 시 displayMessage에 포함용)
att.txtName = txtName;
att.txtUrl = txtUrl;
// 카드 DOM에 txt 버튼 삽입
var card = document.getElementById('card-' + att.id);
if (!card) return;
var btnRow = card.querySelector('div');
if (!btnRow) return;
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px;text-decoration:none;display:inline-block';
var txtBtn = document.createElement('a');
txtBtn.href = txtUrl;
txtBtn.download = txtName;
txtBtn.title = txtName + ' 다운로드';
txtBtn.style.cssText = btnStyle;
txtBtn.innerHTML = '📝 txt ⬇';
// ✕ 버튼 바로 앞에 삽입
var closeBtn = btnRow.lastElementChild;
btnRow.insertBefore(txtBtn, closeBtn);
}
// ── SheetJS / PDF.js 동적 로드 ──────────────────────────────────────────────
var _sheetJsLoaded = false;
function loadSheetJS(cb) {
if (_sheetJsLoaded) return cb();
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
s.onload = function() { _sheetJsLoaded = true; cb(); };
document.head.appendChild(s);
}
var _pdfJsLoaded = false;
function loadPdfJs(cb) {
if (_pdfJsLoaded) return cb();
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
s.onload = function() {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
_pdfJsLoaded = true; cb();
};
document.head.appendChild(s);
}
function timeAgo(ts) {
const diff = Date.now() - ts;
if (diff < 60000) return 'just now';