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:
kim
2026-05-19 16:38:42 +09:00
co-authored by Claude Sonnet 4.6
parent bac5604d5e
commit 2be3edc314
13 changed files with 920 additions and 89 deletions
+179 -49
View File
@@ -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)">&#9664; 미리보기</button>'
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">&#11015; 저장</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">&#11015; 다운로드</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 = '&#9650; 접기';
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 = '&#9664; 미리보기';
}
}, 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 = '&#128190; 저장';
if (res.success) { btn.textContent = '✓ 저장됨'; setTimeout(function() { btn.innerHTML = '&#128190; 저장'; }, 2000); }
else alert('저장 실패: ' + (res.error || '알 수 없는 오류'));
})
.catch(function(e) { btn.disabled = false; btn.innerHTML = '&#128190; 저장'; 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>