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:
+110
-11
@@ -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  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, '"');
|
||||
var safeOrig = url.replace(/"/g, '"');
|
||||
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, '"');
|
||||
var safeOrig = url.replace(/"/g, '"');
|
||||
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="md-img-err">✖ 이미지를 불러올 수 없습니다.</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');
|
||||
|
||||
Reference in New Issue
Block a user