Refactor project layout and fix workspace/subagent paths

- Remove global workspace (workspace/) — all data migrated to .smallclaw/
- Move ppt/ skins & templates to .smallclaw/skills/presenter/ppt/
- Add .smallclaw/templates/ for user bootstrap files
- Move dental_images to .smallclaw/databases/, update 1,485 DB entries
- Fix subagent store paths to use SMALLCLAW_DATA_DIR instead of workspace.path
- Per-user Telegram bots: MultiUserTelegramManager, vault-stored tokens
- Fix Telegram token persistence (vault fallback, strip from config.json)
- Fix session migration: delete global originals even when skipping
- Delete unused root images, MD docs, tests/, smallclawworkspace/ artifacts
- Add meteorologist skill, counselor skill, presenter skill scaffold
- Strip workspace.path from config.json (was causing path confusion)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-28 15:21:21 +09:00
co-authored by Claude Sonnet 4.6
parent d7eb0abc6d
commit 5608b83850
146 changed files with 1653 additions and 14870 deletions
+110 -11
View File
@@ -691,10 +691,14 @@ function toggleSteps(id) {
}
function renderFileDownloads(steps, processEntries) {
const seenUrls = new Set();
const seenUrls = new Set(); // normalized (decoded) URLs
const buttons = [];
const URL_RE = /\/api\/files\/[^\s)"'\]]+/g;
function normalizeUrl(u) {
try { return decodeURIComponent(u); } catch { return u; }
}
function extractUrls(text) {
const urls = [];
let m;
@@ -704,10 +708,11 @@ function renderFileDownloads(steps, processEntries) {
// Pre-mark URLs already present in msg.content so tool-result duplicates are suppressed.
// renderMarkdown() handles content links — we must not render a second button for the same URL.
// Normalize both sides (decode percent-encoding) before comparing so Korean paths match.
if (arguments.length > 2 && arguments[2]) {
const contentText = String(arguments[2]);
if (contentText.indexOf('/api/files/') !== -1) {
for (const u of extractUrls(contentText)) seenUrls.add(u);
for (const u of extractUrls(contentText)) seenUrls.add(normalizeUrl(u));
URL_RE.lastIndex = 0;
}
}
@@ -733,8 +738,9 @@ function renderFileDownloads(steps, processEntries) {
const _imgExts = /\.(jpg|jpeg|png|gif|webp|heic|heif|bmp|svg|avif|tiff|tif)(\?|$)/i;
for (const rawUrl of allUrls) {
if (seenUrls.has(rawUrl)) continue;
seenUrls.add(rawUrl);
const normUrl = normalizeUrl(rawUrl);
if (seenUrls.has(normUrl)) continue;
seenUrls.add(normUrl);
const decoded = (() => { try { return decodeURIComponent(rawUrl); } catch { return rawUrl; } })();
if (_imgExts.test(decoded)) continue; // images shown inline, no download button needed
const filename = decoded.split('/').pop() || 'download';
@@ -2941,9 +2947,11 @@ async function loadUsersTab() {
<div style="width:28px;height:28px;border-radius:50%;background:var(--brand);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700">${u.username.charAt(0).toUpperCase()}</div>
<div style="flex:1"><strong style="font-size:13px">${u.username}</strong> <span style="color:var(--muted);font-size:11px">(${u.role})</span></div>
<span style="color:var(--muted);font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''}</span>
<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer" onclick="toggleUserTelegram('${u.username}')">✈ 텔레그램</button>
<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer" onclick="toggleUserEmail('${u.username}')">✉ 이메일</button>
${u.username !== data.current_user ? `<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:8px;cursor:pointer" onclick="removeUser('${u.username}')">삭제</button>` : '<span style="font-size:11px;color:var(--ok);font-weight:600">현재</span>'}
</div>
<div id="user-telegram-panel-${u.username}" style="display:none;margin-top:8px;padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px"></div>
<div id="user-email-panel-${u.username}" style="display:none;margin-top:8px;padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px"></div>
</div>
`).join('');
@@ -2982,6 +2990,79 @@ async function removeUser(username) {
} catch (e) { alert(e.message || '삭제 실패'); }
}
// --- Per-User Telegram Bot ---------------------------------------------------
function toggleUserTelegram(username) {
const panel = document.getElementById(`user-telegram-panel-${username}`);
if (!panel) return;
if (panel.style.display === 'none') {
panel.style.display = 'block';
loadUserTelegramBot(username);
} else {
panel.style.display = 'none';
}
}
async function loadUserTelegramBot(username) {
const panel = document.getElementById(`user-telegram-panel-${username}`);
if (!panel) return;
panel.innerHTML = '<div style="font-size:12px;color:var(--muted)">불러오는 중...</div>';
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`);
const statusColor = data.running ? 'var(--ok)' : 'var(--muted)';
const statusText = data.running ? `연결됨 (@${data.botUsername || '?'})` : '중지됨';
panel.innerHTML = `
<div style="font-size:12px;font-weight:600;margin-bottom:8px">텔레그램 전용 봇 — ${username}</div>
<div style="font-size:11px;color:${statusColor};margin-bottom:8px">● ${statusText}</div>
<div style="display:flex;gap:6px;align-items:center">
<input id="tg-token-${username}" type="password" placeholder="${data.hasToken ? '●●●●●●●● (저장됨 — 변경 시 입력)' : '봇 토큰 입력 (BotFather에서 발급)'}"
style="flex:1;padding:5px 8px;border:1px solid var(--line);border-radius:6px;font-size:12px;background:var(--panel-1);color:var(--text)">
<button onclick="saveUserTelegramBot('${username}')" style="padding:5px 12px;font-size:11px;background:var(--brand);color:#fff;border:none;border-radius:6px;cursor:pointer">저장</button>
${data.hasToken ? `<button onclick="deleteUserTelegramBot('${username}')" style="padding:5px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:6px;cursor:pointer">삭제</button>` : ''}
</div>
<div id="tg-bot-msg-${username}" style="font-size:11px;margin-top:6px"></div>
`;
} catch (e) {
panel.innerHTML = `<div style="font-size:12px;color:var(--err)">${e.message}</div>`;
}
}
async function saveUserTelegramBot(username) {
const input = document.getElementById(`tg-token-${username}`);
const msg = document.getElementById(`tg-bot-msg-${username}`);
const token = input ? input.value.trim() : '';
if (!token) { if (msg) msg.textContent = '토큰을 입력해 주세요.'; return; }
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ botToken: token }),
});
if (data.success) {
if (msg) { msg.style.color = 'var(--ok)'; msg.textContent = '저장됨. 봇 연결 중...'; }
setTimeout(() => loadUserTelegramBot(username), 2000);
} else {
if (msg) { msg.style.color = 'var(--err)'; msg.textContent = data.error || '저장 실패'; }
}
} catch (e) {
if (msg) { msg.style.color = 'var(--err)'; msg.textContent = e.message; }
}
}
async function deleteUserTelegramBot(username) {
if (!confirm(`${username}의 텔레그램 봇을 삭제하시겠습니까?`)) return;
const msg = document.getElementById(`tg-bot-msg-${username}`);
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/telegram-bot`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ botToken: '' }),
});
if (data.success) loadUserTelegramBot(username);
else if (msg) { msg.style.color = 'var(--err)'; msg.textContent = data.error || '삭제 실패'; }
} catch (e) {
if (msg) { msg.style.color = 'var(--err)'; msg.textContent = e.message; }
}
}
// --- Per-User Email Accounts -------------------------------------------------
function toggleUserEmail(username) {
@@ -5681,16 +5762,16 @@ function renderMarkdown(text, opts) {
return pre + '<div class="map-embed-wrap"><iframe class="map-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Google 지도에서 열기 ↗</a></div></div>' + post;
});
// Windy URLs on their own line → inline iframe
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
// Windy URLs in <img src="..."> (model used ![alt](url) syntax) → inline iframe [must run first]
html = html.replace(/<img\s[^>]*src="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*\/?>/gi, function(m, url) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>' + post;
return '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>';
});
// Windy URLs inside anchor tags (markdown links) → inline iframe
// Windy URLs inside anchor tags (markdown links) → inline iframe [must run BEFORE bare-URL regex]
html = html.replace(/<a\s[^>]*href="(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^"]*)"[^>]*>[^<]*<\/a>/gi, function(m, url) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
@@ -5699,6 +5780,15 @@ function renderMarkdown(text, opts) {
return '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>';
});
// Windy URLs on their own line → inline iframe [runs after anchor regex to avoid double-render]
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/(?:embed\.windy\.com|(?:www\.)?windy\.com)[^\s<>")\n]*)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var embedUrl = windyToEmbedUrl(url);
if (!embedUrl) return m;
var safeEmbed = embedUrl.replace(/"/g, '&quot;');
var safeOrig = url.replace(/"/g, '&quot;');
return pre + '<div class="windy-embed-wrap"><iframe class="windy-embed" src="' + safeEmbed + '" allowfullscreen loading="lazy"></iframe><div class="map-embed-link"><a href="' + safeOrig + '" target="_blank" rel="noopener">Windy에서 열기 ↗</a></div></div>' + post;
});
// Bare image URLs on their own line → inline image
html = html.replace(/((?:^|\n)[ \t]*)(https?:\/\/[^\s<>"()\n#?]+\.(?:jpe?g|png|gif|webp|svg|bmp|avif|tiff)(?:[?#][^\s<>"()\n]*)?)([ \t]*)(?=\n|$)/gim, function(m, pre, url, post) {
var onerr = "this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span class=&quot;md-img-err&quot;>&#x2716; 이미지를 불러올 수 없습니다.</span>')";
@@ -6320,9 +6410,18 @@ function togglePptxPreview(fileUrl, containerId, btn) {
container.innerHTML = '<span style="color:#ef4444;font-size:13px;padding:12px">Preview failed: ' + escHtml(data.error || 'No slides found') + '</span>';
return;
}
container.innerHTML = data.images.map(function(imgUrl, i) {
return '<img src="' + imgUrl + '" alt="Slide ' + (i + 1) + '" onclick="openImageZoom(this.src)" loading="lazy" />';
}).join('');
// img 태그는 Bearer 헤더를 보낼 수 없어서 auth 없이 요청 → user context 미설정 → 파일 못 찾음.
// fetch()로 auth 포함해 가져온 뒤 Blob URL로 교체.
Promise.all(data.images.map(function(imgUrl) {
return fetch(imgUrl, { headers: authHeaders() })
.then(function(r) { return r.ok ? r.blob() : Promise.reject(new Error('HTTP ' + r.status)); })
.then(function(blob) { return URL.createObjectURL(blob); })
.catch(function() { return imgUrl; });
})).then(function(srcs) {
container.innerHTML = srcs.map(function(src, i) {
return '<img src="' + src + '" alt="Slide ' + (i + 1) + '" onclick="openImageZoom(this.src)" loading="lazy" />';
}).join('');
});
})
.catch(function(err) {
btn.classList.remove('loading');
+6
View File
@@ -26,6 +26,12 @@
<script src="https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js"></script>
<script src="https://cdn.jsdelivr.net/combine/npm/tone@14,npm/@magenta/music@1.23.1/es6/core.js,npm/html-midi-player@1.6.0" defer></script>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script>
// Prevent deferred UMD libs (tone, @magenta/music, html-midi-player) from
// using Monaco's AMD define() — multiple anonymous define() calls in one
// combined script cause "Can only have one anonymous define call per script".
if (window.define) window.define.amd = false;
</script>
</head>
<body>