Add accountant/investor skills, Excel editor, email attachment fixes
Skills:
- Add accountant (CPA) and investor (CFA) skills with site: hints
- Add site: hints to lawyer, psychiatrist, musician skills
- Allow multiple skills active simultaneously (remove exclusive mode)
Tools:
- Add excel_read / excel_write tools (openpyxl-based)
- Fix python_eval packages: retry with --break-system-packages on PEP 668 failure
- Fix email_read: spaces→underscores in filenames, return /api/files/ links,
remove absolute savedPaths from data, send SSE 'files' event for attachments
Server:
- FILE_OP v2: fall through to primary when secondary model unavailable
- Add PUT /api/files/{*filePath} endpoint for xlsx editor save
UI:
- xlsx viewer: Luckysheet modal (lazy CDN load, ~3MB on first use)
with edit mode, 💾 save button, Nanum Gothic font
- Email attachment preview: SSE 'files' event appends file links to final reply
- ✏️ 편집 button (blue) for xlsx cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+179
-49
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CherryClaw</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="cherry_logo.png">
|
||||
<link rel="apple-touch-icon" sizes="128x128" href="cherry_logo.png">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
|
||||
@@ -780,13 +783,21 @@
|
||||
.download-btn-error { display: inline-flex; align-items: center; gap: 4px; background: #fee2e2; color: #991b1b !important; padding: 4px 10px; border-radius: 6px; font-weight: 600; font-size: 12px; margin: 4px 2px; cursor: default; text-decoration: none !important; }
|
||||
:root[data-theme="dark"] .download-btn-error { background: #3f1515; color: #fca5a5 !important; }
|
||||
.pptx-file-row { margin: 4px 0; }
|
||||
.pptx-preview-btn { display: inline-flex; align-items: center; gap: 4px; background: linear-gradient(135deg, #10b981, #059669); color: #fff; padding: 4px 10px; border-radius: 6px; border: none; font-weight: 600; font-size: 12px; cursor: pointer; margin-left: 4px; transition: all 0.15s; box-shadow: 0 1px 3px rgba(16,185,129,0.25); }
|
||||
.pptx-preview-btn { display: inline-flex; align-items: center; gap: 4px; background: linear-gradient(135deg, #10b981, #059669); color: #fff; padding: 4px 10px; border-radius: 6px; border: none; font-weight: 600; font-size: 12px; cursor: pointer; margin-left: 4px; transition: all 0.15s; box-shadow: 0 1px 3px rgba(16,185,129,0.25); box-sizing: border-box; line-height: 1.4; vertical-align: middle; }
|
||||
.pptx-preview-btn:hover { background: linear-gradient(135deg, #059669, #047857); transform: translateY(-1px); }
|
||||
.pptx-preview-btn.loading { opacity: 0.6; pointer-events: none; }
|
||||
.pptx-preview-strip { margin-top: 8px; display: flex; gap: 8px; overflow-x: auto; padding: 8px 4px; border-radius: 8px; background: var(--panel-2, #f3f4f6); }
|
||||
:root[data-theme="dark"] .pptx-preview-strip { background: var(--panel-2, #1e293b); }
|
||||
.pptx-preview-strip img { height: 120px; border-radius: 4px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; flex-shrink: 0; }
|
||||
.pptx-preview-strip img:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
|
||||
/* Luckysheet font & rendering overrides */
|
||||
#lucky-container, #lucky-container * {
|
||||
font-family: "Nanum Gothic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
#lucky-container .iconfont { font-family: "iconfont" !important; }
|
||||
#lucky-container .luckysheet-wa { position: absolute !important; }
|
||||
#image-zoom-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 10000; align-items: center; justify-content: center; cursor: pointer; }
|
||||
#image-zoom-modal img { max-width: 95vw; max-height: 95vh; border-radius: 8px; box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
|
||||
.msg-staged { display: flex; flex-direction: column; gap: 10px; }
|
||||
@@ -5105,6 +5116,7 @@ async function sendChat(queuedMessage = null) {
|
||||
const turnThinkingSeen = new Set();
|
||||
let sawExecuteModeThisTurn = false;
|
||||
let sawToolActivityThisTurn = false;
|
||||
let turnFileLinks = [];
|
||||
const collectTurnThinking = (value) => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return;
|
||||
@@ -5478,6 +5490,12 @@ async function sendChat(queuedMessage = null) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'files':
|
||||
if (Array.isArray(event.links)) {
|
||||
for (const lnk of event.links) turnFileLinks.push(lnk);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
addProcessEntry('error', event.message);
|
||||
break;
|
||||
@@ -5499,6 +5517,16 @@ async function sendChat(queuedMessage = null) {
|
||||
}
|
||||
|
||||
if (finalReply) {
|
||||
// Append file cards for any attachments sent via the 'files' SSE event
|
||||
if (turnFileLinks.length > 0) {
|
||||
const seen = new Set();
|
||||
const newLinks = turnFileLinks.filter(l => { const k = l.url || l.name; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
// Only append links not already present as markdown in the reply
|
||||
const toAppend = newLinks.filter(l => !finalReply.includes(l.url || ''));
|
||||
if (toAppend.length > 0) {
|
||||
finalReply += '\n\n' + toAppend.map(l => `[${l.name}](${l.url})`).join('\n');
|
||||
}
|
||||
}
|
||||
const finalStep = allSteps.find(s => s.finalAnswer);
|
||||
const mergedThinking = turnThinkingBuffer.join('\n\n').trim();
|
||||
const shouldAttachThinkingPanel = sawExecuteModeThisTurn || sawToolActivityThisTurn;
|
||||
@@ -8537,10 +8565,10 @@ function _renderLinkButton(text, url, opts) {
|
||||
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
||||
+ '<span style="font-size:14px">📊</span>'
|
||||
+ '<span style="font-weight:600;color:var(--text)">' + escHtml(text) + '</span>'
|
||||
+ '<button class="pptx-preview-btn" data-xlsx-url="' + xlsxSafeUrl + '" onclick="toggleXlsxEmbedBtn(this)">◀ 미리보기</button>'
|
||||
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">⬇ 저장</a>'
|
||||
+ '<button class="pptx-preview-btn" data-xlsx-url="' + xlsxSafeUrl + '" data-xlsx-save-url="' + xlsxSafeUrl + '" onclick="toggleXlsxEmbedBtn(this)" style="background:linear-gradient(135deg,#3b82f6,#2563eb)">✏️ 편집</button>'
|
||||
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">⬇ 다운로드</a>'
|
||||
+ '</div>'
|
||||
+ '<div class="xlsx-preview-body" style="display:none;max-height:340px;overflow:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:8px;padding:10px"></div>'
|
||||
+ ''
|
||||
+ '</div>';
|
||||
}
|
||||
var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase());
|
||||
@@ -8577,53 +8605,116 @@ function togglePdfEmbed(url, containerId, btn) {
|
||||
}
|
||||
}
|
||||
|
||||
var _luckyCurrentSaveUrl = null;
|
||||
|
||||
function toggleXlsxEmbedBtn(btn) {
|
||||
var card = btn.closest('.xlsx-card');
|
||||
if (!card) return;
|
||||
var container = card.querySelector('.xlsx-preview-body');
|
||||
if (!container) return;
|
||||
var url = encodeURI(btn.getAttribute('data-xlsx-url'));
|
||||
if (container.style.display === 'none') {
|
||||
container.style.display = 'block';
|
||||
btn.innerHTML = '▲ 접기';
|
||||
if (!container.dataset.loaded) {
|
||||
container.innerHTML = '불러오는 중…';
|
||||
fetch(url, { headers: authHeaders() })
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.arrayBuffer();
|
||||
})
|
||||
.then(function(buf) {
|
||||
loadSheetJS(function() {
|
||||
try {
|
||||
var wb = XLSX.read(new Uint8Array(buf), { type: 'array' });
|
||||
var html = '';
|
||||
wb.SheetNames.forEach(function(sn) {
|
||||
var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 });
|
||||
html += '<div style="font-weight:700;font-size:11px;margin-bottom:6px">' + escHtml(sn) + '</div>';
|
||||
html += '<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:11px">';
|
||||
rows.slice(0, 50).forEach(function(row, ri) {
|
||||
html += '<tr>';
|
||||
row.forEach(function(cell) {
|
||||
var tag = ri === 0 ? 'th' : 'td';
|
||||
html += '<' + tag + ' style="border:1px solid var(--line);padding:3px 8px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + '</' + tag + '>';
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
if (rows.length > 50) html += '<tr><td colspan="99" style="text-align:center;color:var(--muted);font-size:10px;padding:4px">… ' + (rows.length - 50) + '행 더</td></tr>';
|
||||
html += '</table></div><br>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
container.dataset.loaded = '1';
|
||||
} catch(e) { container.textContent = 'Excel 파싱 오류: ' + e.message; }
|
||||
var card = btn.closest('.xlsx-card');
|
||||
var nameEl = card ? card.querySelector('span[style*="font-weight"]') : null;
|
||||
var title = nameEl ? nameEl.textContent : url.split('/').pop();
|
||||
var saveUrl = card ? card.querySelector('[data-xlsx-save-url]')?.getAttribute('data-xlsx-save-url') : null;
|
||||
openLuckyModal(url, title, saveUrl);
|
||||
}
|
||||
|
||||
function openLuckyModal(url, title, saveUrl) {
|
||||
var modal = document.getElementById('lucky-modal');
|
||||
var titleEl = document.getElementById('lucky-modal-title');
|
||||
var container = document.getElementById('lucky-container');
|
||||
if (!modal || !container) return;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
titleEl.textContent = title || '스프레드시트';
|
||||
_luckyCurrentSaveUrl = saveUrl || null;
|
||||
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#666;font-size:13px">Luckysheet 로딩 중… (최초 1회 ~3MB)</div>';
|
||||
|
||||
fetch(url, { headers: authHeaders() })
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||
.then(function(buf) {
|
||||
loadLuckysheet(function() {
|
||||
if (typeof LuckyExcel === 'undefined' || typeof luckysheet === 'undefined') {
|
||||
container.innerHTML = '<div style="padding:20px;color:red">Luckysheet 로드 실패 — 네트워크 연결을 확인하세요.</div>'; return;
|
||||
}
|
||||
var file = new File([buf], 'sheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
LuckyExcel.transformExcelToLucky(file, function(exportInfo) {
|
||||
container.innerHTML = '';
|
||||
try { luckysheet.destroy(); } catch(e) {}
|
||||
luckysheet.create({
|
||||
container: 'lucky-container',
|
||||
data: exportInfo.sheets,
|
||||
title: '',
|
||||
lang: 'en',
|
||||
showinfobar: false,
|
||||
showstatisticBar: false,
|
||||
sheetBottomConfig: true,
|
||||
allowUpdate: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
showToolbar: true,
|
||||
plugins: [],
|
||||
defaultFontSize: 11,
|
||||
defaultFontName: 'Nanum Gothic',
|
||||
});
|
||||
})
|
||||
.catch(function(e) { container.textContent = '불러오기 실패: ' + e.message; });
|
||||
}
|
||||
} else {
|
||||
container.style.display = 'none';
|
||||
btn.innerHTML = '◀ 미리보기';
|
||||
}
|
||||
}, function(err) {
|
||||
container.innerHTML = '<div style="padding:20px;color:red">Excel 변환 오류: ' + escHtml(String(err?.message || err)) + '</div>';
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function(e) {
|
||||
container.innerHTML = '<div style="padding:20px;color:red">불러오기 실패: ' + escHtml(e.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function closeLuckyModal() {
|
||||
var modal = document.getElementById('lucky-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
try { luckysheet.destroy(); } catch(e) {}
|
||||
_luckyCurrentSaveUrl = null;
|
||||
}
|
||||
|
||||
// Close on backdrop click
|
||||
document.getElementById('lucky-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeLuckyModal();
|
||||
});
|
||||
|
||||
function saveLuckyModal() {
|
||||
var url = _luckyCurrentSaveUrl;
|
||||
if (!url) { alert('저장할 파일 경로가 없습니다.'); return; }
|
||||
var btn = document.getElementById('lucky-modal-save');
|
||||
var relPath = url.replace(/^\/api\/files\//, '');
|
||||
loadSheetJS(function() {
|
||||
try {
|
||||
// Luckysheet → SheetJS aoa conversion
|
||||
var sheets = luckysheet.getAllSheets();
|
||||
var wb = XLSX.utils.book_new();
|
||||
sheets.forEach(function(sheet) {
|
||||
var celldata = sheet.celldata || [];
|
||||
if (!celldata.length) { XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([[]]), sheet.name || 'Sheet'); return; }
|
||||
var maxR = 0, maxC = 0;
|
||||
celldata.forEach(function(c) { maxR = Math.max(maxR, c.r); maxC = Math.max(maxC, c.c); });
|
||||
var aoa = [];
|
||||
for (var r = 0; r <= maxR; r++) aoa.push(new Array(maxC + 1).fill(''));
|
||||
celldata.forEach(function(c) {
|
||||
if (c.v == null) return;
|
||||
var val = (typeof c.v === 'object') ? (c.v.m !== undefined ? c.v.m : (c.v.v !== undefined ? c.v.v : '')) : c.v;
|
||||
aoa[c.r][c.c] = val;
|
||||
});
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), sheet.name || 'Sheet');
|
||||
});
|
||||
var xlsxBuf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||||
btn.disabled = true; btn.textContent = '저장 중…';
|
||||
fetch('/api/files/' + relPath, {
|
||||
method: 'PUT', headers: authHeaders(),
|
||||
body: new Blob([xlsxBuf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
btn.disabled = false; btn.innerHTML = '💾 저장';
|
||||
if (res.success) { btn.textContent = '✓ 저장됨'; setTimeout(function() { btn.innerHTML = '💾 저장'; }, 2000); }
|
||||
else alert('저장 실패: ' + (res.error || '알 수 없는 오류'));
|
||||
})
|
||||
.catch(function(e) { btn.disabled = false; btn.innerHTML = '💾 저장'; alert('저장 오류: ' + e.message); });
|
||||
} catch(e) { alert('Excel 변환 오류: ' + e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStagedExcelPreview(id) {
|
||||
@@ -9241,7 +9332,7 @@ function pdfPageToText(items, pageNum, totalPages) {
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ── SheetJS / PDF.js 동적 로드 ──────────────────────────────────────────────
|
||||
// ── SheetJS / PDF.js / x-spreadsheet 동적 로드 ──────────────────────────────────────────────
|
||||
var _sheetJsLoaded = false;
|
||||
function loadSheetJS(cb) {
|
||||
if (_sheetJsLoaded) return cb();
|
||||
@@ -9251,6 +9342,34 @@ function loadSheetJS(cb) {
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
var _luckyLoaded = false;
|
||||
function loadLuckysheet(cb) {
|
||||
if (_luckyLoaded) return cb();
|
||||
var cssList = [
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/css/pluginsCss.css',
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/plugins.css',
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/css/luckysheet.css',
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/assets/iconfont/iconfont.css',
|
||||
];
|
||||
cssList.forEach(function(href) {
|
||||
var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href;
|
||||
document.head.appendChild(l);
|
||||
});
|
||||
var scripts = [
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/js/plugin.js',
|
||||
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/luckysheet.umd.js',
|
||||
'https://cdn.jsdelivr.net/npm/luckyexcel@1.0.1/dist/luckyexcel.umd.js',
|
||||
];
|
||||
function next(i) {
|
||||
if (i >= scripts.length) { _luckyLoaded = true; cb(); return; }
|
||||
var s = document.createElement('script');
|
||||
s.src = scripts[i]; s.onload = function() { next(i + 1); };
|
||||
s.onerror = function() { next(i + 1); }; // 실패해도 진행
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
next(0);
|
||||
}
|
||||
|
||||
var _pdfJsLoaded = false;
|
||||
function loadPdfJs(cb) {
|
||||
if (_pdfJsLoaded) return cb();
|
||||
@@ -11623,5 +11742,16 @@ function handleErrorResponseWsEvent(e) {
|
||||
<div id="image-zoom-modal" onclick="closeImageZoom()">
|
||||
<img id="image-zoom-img" src="" alt="Zoomed image" />
|
||||
</div>
|
||||
<!-- ── Luckysheet Modal ─────────────────────────────────────────── -->
|
||||
<div id="lucky-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.55);align-items:center;justify-content:center">
|
||||
<div style="background:#fff;width:96vw;height:92vh;border-radius:10px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.35)">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:#f5f5f5;border-bottom:1px solid #ddd;flex-shrink:0">
|
||||
<span id="lucky-modal-title" style="font-weight:700;font-size:14px;color:#222;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button id="lucky-modal-save" onclick="saveLuckyModal()" style="background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:13px;font-weight:600;cursor:pointer">💾 저장</button>
|
||||
<button onclick="closeLuckyModal()" style="background:#e5e7eb;color:#333;border:none;border-radius:6px;padding:5px 12px;font-size:13px;cursor:pointer">✕ 닫기</button>
|
||||
</div>
|
||||
<div id="lucky-container" style="flex:1;position:relative;color-scheme:light"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user