Skip to main content
Glama

Scratchpad MCP

by pc035860
viewer-controls.js18.6 kB
// Viewer Controls: floating panel for line numbering and auto refresh/scroll (function () { const detailEl = document.querySelector('.workflow-detail'); if (!detailEl) return; const workflowId = detailEl.dataset.workflowId; const container = document.getElementById('scratchpads-container'); if (!container) return; const PREFS = { lineOrder: `viewer:${workflowId}:lineOrder`, // 'asc' | 'desc' autoRefresh: `viewer:${workflowId}:autoRefresh`, // '1' | '0' autoScroll: `viewer:${workflowId}:autoScroll`, // '1' | '0' updateMode: `viewer:${workflowId}:updateMode`, // 'auto' | 'sse' | 'json' | 'html' }; // Helpers const getPref = (k, def) => window.localStorage.getItem(k) ?? def; const setPref = (k, v) => window.localStorage.setItem(k, v); // Focused scratchpad detection via URL (?sp= or hash #sp=) function getFocusedScratchpadId() { const u = new URL(window.location.href); const fromQuery = u.searchParams.get('sp'); if (fromQuery) return fromQuery; const hash = u.hash || ''; if (hash.startsWith('#')) { try { const p = new URLSearchParams(hash.slice(1)); return p.get('sp'); } catch {} } return null; } function createControls() { const panel = document.createElement('div'); panel.className = 'floating-controls'; panel.innerHTML = ` <button id="toggle-view" class="toggle-btn" type="button" aria-pressed="false">視圖: 渲染</button> <button id="toggle-line" class="toggle-btn" type="button" aria-pressed="false">行號: 正序</button> <button id="toggle-refresh" class="toggle-btn" type="button" aria-pressed="false" title="每隔數秒重新載入內容">自動刷新: 關</button> <button id="toggle-scroll" class="toggle-btn" type="button" aria-pressed="false" title="內容更新後自動捲到底部">自動捲動: 關</button> <button id="toggle-focus" class="toggle-btn" type="button" aria-pressed="false" title="切換是否聚焦單一 scratchpad">聚焦: 關</button> `; // 將面板插入到 scratchpads 區塊,貼齊渲染容器 const host = document.querySelector('.scratchpads-section') || detailEl; host.insertBefore(panel, host.firstChild); function positionPanel() { const rect = host.getBoundingClientRect(); const spacing = 16; // px const panelWidth = panel.offsetWidth || 300; const desiredLeft = Math.round(rect.right + spacing); const fitsRight = desiredLeft + panelWidth <= window.innerWidth - 8; if (fitsRight) { // 固定在視窗中,貼齊容器右外側 panel.style.position = 'fixed'; panel.style.left = desiredLeft + 'px'; // 讓面板大致與容器頂對齊,並在視窗內夾取 const desiredTop = Math.round( Math.max(16, Math.min(rect.top, window.innerHeight - (panel.offsetHeight || 0) - 16)) ); panel.style.top = desiredTop + 'px'; panel.style.right = ''; panel.style.marginLeft = ''; } else { // 空間不足,退回容器內側右上角(sticky 效果) panel.style.position = 'sticky'; panel.style.left = ''; // 當退回容器內側時,調整 top 位置避免與 sticky header 重疊 panel.style.top = '3.7rem'; panel.style.right = ''; panel.style.marginLeft = 'auto'; } } // 初始定位 + 事件綁定 positionPanel(); window.addEventListener('scroll', positionPanel, { passive: true }); window.addEventListener('resize', positionPanel); // Restore state const viewBtn = panel.querySelector('#toggle-view'); const lineBtn = panel.querySelector('#toggle-line'); const refreshBtn = panel.querySelector('#toggle-refresh'); const scrollBtn = panel.querySelector('#toggle-scroll'); const focusBtn = panel.querySelector('#toggle-focus'); // Default: rendered view const initialView = getPref(`viewer:${workflowId}:view`, 'rendered'); const initialLine = getPref(PREFS.lineOrder, 'asc'); const initialRefresh = getPref(PREFS.autoRefresh, '0') === '1'; const initialScroll = getPref(PREFS.autoScroll, '0') === '1'; function updateViewBtnLabel(mode) { viewBtn.textContent = `視圖: ${mode === 'raw' ? '原始' : '渲染'}`; viewBtn.setAttribute('aria-pressed', mode === 'raw' ? 'true' : 'false'); } function updateLineBtnLabel(order) { lineBtn.textContent = `行號: ${order === 'desc' ? '倒序' : '正序'}`; lineBtn.setAttribute('aria-pressed', order === 'desc' ? 'true' : 'false'); } function updateToggle(btn, on) { btn.textContent = `${btn.id === 'toggle-refresh' ? '自動刷新' : '自動捲動'}: ${on ? '開' : '關'}`; btn.setAttribute('aria-pressed', on ? 'true' : 'false'); } updateViewBtnLabel(initialView); updateLineBtnLabel(initialLine); updateToggle(refreshBtn, initialRefresh); updateToggle(scrollBtn, initialScroll); updateLineToggleVisibility(initialView); updateFocusBtn(); applyViewMode(initialView); viewBtn.addEventListener('click', () => { const current = getPref(`viewer:${workflowId}:view`, 'rendered'); const next = current === 'raw' ? 'rendered' : 'raw'; setPref(`viewer:${workflowId}:view`, next); updateViewBtnLabel(next); applyViewMode(next); renderLineNumbersForAll(); updateLineToggleVisibility(next); }); lineBtn.addEventListener('click', () => { const current = getPref(PREFS.lineOrder, 'asc'); const next = current === 'desc' ? 'asc' : 'desc'; setPref(PREFS.lineOrder, next); updateLineBtnLabel(next); renderLineNumbersForAll(); }); refreshBtn.addEventListener('click', () => { const cur = getPref(PREFS.autoRefresh, '0') === '1'; const next = !cur; setPref(PREFS.autoRefresh, next ? '1' : '0'); updateToggle(refreshBtn, next); }); scrollBtn.addEventListener('click', () => { const cur = getPref(PREFS.autoScroll, '0') === '1'; const next = !cur; setPref(PREFS.autoScroll, next ? '1' : '0'); updateToggle(scrollBtn, next); }); // 聚焦切換:若未聚焦則預設聚焦至最後一個 scratchpad;若已聚焦則退出並回 workflow 級 focusBtn.addEventListener('click', () => { const sid = getFocusedScratchpadId(); const url = new URL(window.location.href); if (sid) { // 退出聚焦:移除 ?sp / #sp url.searchParams.delete('sp'); if (url.hash && url.hash.includes('sp=')) { try { const hs = new URLSearchParams(url.hash.slice(1)); hs.delete('sp'); url.hash = hs.toString() ? `#${hs.toString()}` : ''; } catch { url.hash = ''; } } } else { // 進入聚焦:鎖定最後一個 scratchpad const target = getLastScratchpadId(); if (!target) return; url.searchParams.set('sp', target); } // 以重新載入啟動對應的更新流(簡化狀態切換與清理) window.location.href = url.toString(); }); } function updateLineToggleVisibility(mode) { const btn = document.querySelector('.floating-controls #toggle-line'); if (!btn) return; btn.style.display = mode === 'raw' ? 'inline-flex' : 'none'; } function renderLineNumbersForAll() { const order = getPref(PREFS.lineOrder, 'asc'); const mode = getPref(`viewer:${workflowId}:view`, 'rendered'); const items = container.querySelectorAll('.markdown-with-lines'); // 在渲染視圖下不顯示行號,並清空 gutter if (mode !== 'raw') { items.forEach((wrap) => { const gutter = wrap.querySelector('.line-gutter'); if (gutter) gutter.innerHTML = ''; }); return; } items.forEach((wrap) => { const gutter = wrap.querySelector('.line-gutter'); if (!gutter) return; const lineCount = parseInt(wrap.getAttribute('data-line-count') || '0', 10); if (!Number.isFinite(lineCount) || lineCount <= 0) { gutter.innerHTML = ''; return; } // Efficient bulk render const frag = document.createDocumentFragment(); if (order === 'desc') { for (let i = lineCount; i >= 1; i--) { const s = document.createElement('span'); s.className = 'line-num'; s.textContent = String(i); frag.appendChild(s); } } else { for (let i = 1; i <= lineCount; i++) { const s = document.createElement('span'); s.className = 'line-num'; s.textContent = String(i); frag.appendChild(s); } } gutter.replaceChildren(frag); }); } function decodeRaw(b64) { try { return decodeURIComponent(escape(window.atob(b64))); } catch { // Fallback for UTF-8 decoding in older browsers return atob(b64); } } function applyViewMode(mode) { document.body.classList.toggle('view-raw', mode === 'raw'); document.body.classList.toggle('view-rendered', mode !== 'raw'); const items = container.querySelectorAll('.markdown-with-lines'); items.forEach((wrap) => { let rawEl = wrap.querySelector('.raw-view'); if (mode === 'raw') { if (!rawEl) { const b64 = wrap.getAttribute('data-raw-b64') || ''; const pre = document.createElement('pre'); pre.className = 'raw-view'; pre.textContent = b64 ? decodeRaw(b64) : ''; wrap.appendChild(pre); } } }); } function replaceScratchpadsWithHTML(html) { // 解析回傳 HTML,若包含外層 #scratchpads-container,改為 child-level 置換避免巢狀 try { const doc = new DOMParser().parseFromString(html, 'text/html'); const newContainer = doc.getElementById('scratchpads-container'); if (newContainer) { container.replaceChildren(...Array.from(newContainer.childNodes)); } else { // 非完整文件情況,使用臨時容器取子節點 const tmp = document.createElement('div'); tmp.innerHTML = html; const inner = tmp.querySelector('#scratchpads-container'); if (inner) { container.replaceChildren(...Array.from(inner.childNodes)); } else { // 後退保險:直接覆寫(理論上不會用到) container.innerHTML = html; } } } catch { container.innerHTML = html; } renderLineNumbersForAll(); const currentView = getPref(`viewer:${workflowId}:view`, 'rendered'); applyViewMode(currentView); updateLineToggleVisibility(currentView); if (getPref(PREFS.autoScroll, '0') === '1') { container.scrollTop = container.scrollHeight; window.scrollTo({ top: document.body.scrollHeight }); } } async function fetchScratchpadsFragment() { const resp = await fetch(`/api/workflow/${workflowId}/scratchpads`, { cache: 'no-store' }); if (!resp.ok) return null; return await resp.text(); } async function fetchScratchpadItemHtml(sid) { const resp = await fetch(`/api/scratchpad/${sid}/html`, { cache: 'no-store' }); if (!resp.ok) return null; return await resp.text(); } async function jsonPollLoop() { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let lastUpdated = Number(detailEl.dataset.updatedAt || 0); while (true) { const enabled = getPref(PREFS.autoRefresh, '0') === '1'; if (!enabled) { await sleep(5000); continue; } try { const url = `/api/workflow/${workflowId}/summary?since=${lastUpdated}`; const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store', }); if (res.status === 304) { // no change } else if (res.ok) { const data = await res.json(); if (data.changed) { const frag = await fetchScratchpadsFragment(); if (frag) replaceScratchpadsWithHTML(frag); lastUpdated = data.updated_at; } } } catch {} await sleep(5000); } } function startSSE() { let es; try { es = new EventSource(`/sse/workflow/${workflowId}`); } catch { return false; } let active = true; const onUpdate = async (evt) => { if (!active) return; const frag = await fetchScratchpadsFragment(); if (frag) replaceScratchpadsWithHTML(frag); }; es.addEventListener('update', onUpdate); es.onerror = () => { // 停用 SSE,回退 JSON try { es.close(); } catch {} active = false; jsonPollLoop(); }; return true; } // 取得最後(最新)一個 scratchpad 的 ID function getLastScratchpadId() { const items = container.querySelectorAll('.scratchpad-item[data-scratchpad-id]'); if (!items.length) return null; return items[items.length - 1].getAttribute('data-scratchpad-id'); } // 更新聚焦按鈕狀態與文字 function updateFocusBtn() { const btn = document.querySelector('.floating-controls #toggle-focus'); if (!btn) return; const sid = getFocusedScratchpadId(); const enabled = !!sid; btn.textContent = `聚焦: ${enabled ? '開(單一 scratchpad)' : '關'}`; btn.setAttribute('aria-pressed', enabled ? 'true' : 'false'); btn.disabled = !enabled && !getLastScratchpadId(); } // Replace a single scratchpad item by id function replaceSingleScratchpadDom(sid, html) { const tmp = document.createElement('div'); tmp.innerHTML = html; const newItem = tmp.querySelector('.scratchpad-item'); if (!newItem) return; const oldItem = container.querySelector(`.scratchpad-item[data-scratchpad-id="${sid}"]`); if (!oldItem) return; oldItem.replaceWith(newItem); renderLineNumbersForAll(); const currentView = getPref(`viewer:${workflowId}:view`, 'rendered'); applyViewMode(currentView); updateLineToggleVisibility(currentView); if (getPref(PREFS.autoScroll, '0') === '1') { newItem.scrollIntoView({ block: 'end' }); } } function startScratchpadSSE(sid) { let es; try { es = new EventSource(`/sse/scratchpad/${sid}`); } catch { return false; } let active = true; es.addEventListener('update', async () => { if (!active) return; const html = await fetchScratchpadItemHtml(sid); if (html) replaceSingleScratchpadDom(sid, html); }); es.onerror = () => { try { es.close(); } catch {} active = false; scratchpadJsonPoll(sid); }; return true; } async function scratchpadJsonPoll(sid) { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let last = 0; try { // seed with current dom time if available (not tracked), so start with 0 } catch {} while (true) { const enabled = getPref(PREFS.autoRefresh, '0') === '1'; if (!enabled) { await sleep(5000); continue; } try { const res = await fetch(`/api/scratchpad/${sid}/summary?since=${last}`, { cache: 'no-store', }); if (res.status === 304) { // no change } else if (res.ok) { const data = await res.json(); if (data.changed) { const html = await fetchScratchpadItemHtml(sid); if (html) replaceSingleScratchpadDom(sid, html); last = data.updated_at; } } } catch {} await sleep(5000); } } // Initialize createControls(); renderLineNumbersForAll(); // 運行更新傳輸:優先 SSE,失敗回退 JSON,若瀏覽器不支援則仍可使用舊方案 const mode = getPref(PREFS.updateMode, 'auto'); const focusedSid = getFocusedScratchpadId(); const trySSE = () => { try { return !!startSSE(); } catch { return false; } }; if (focusedSid) { // Scratchpad-focused transport if (mode === 'sse' || mode === 'auto') { if (!startScratchpadSSE(focusedSid)) scratchpadJsonPoll(focusedSid); } else if (mode === 'json') { scratchpadJsonPoll(focusedSid); } else { // html fallback uses workflow legacy loop (coarse) jsonPollLoop(); } } else if (mode === 'sse') { if (!trySSE()) jsonPollLoop(); } else if (mode === 'json') { jsonPollLoop(); } else if (mode === 'html') { // 最末回退:保留原機制(整頁解析) (async function legacyLoop() { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let lastUpdated = detailEl.dataset.updatedAt || ''; while (true) { const enabled = getPref(PREFS.autoRefresh, '0') === '1'; if (!enabled) { await sleep(5000); continue; } try { const res = await fetch(window.location.href, { headers: { 'X-Partial': 'scratchpads' }, cache: 'no-store', }); const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const newDetail = doc.querySelector('.workflow-detail'); const newUpdated = newDetail ? newDetail.dataset.updatedAt || '' : ''; const newContainer = doc.getElementById('scratchpads-container'); if (newContainer && newUpdated && newUpdated !== lastUpdated) { container.replaceChildren(...Array.from(newContainer.childNodes)); renderLineNumbersForAll(); const currentView = getPref(`viewer:${workflowId}:view`, 'rendered'); applyViewMode(currentView); updateLineToggleVisibility(currentView); lastUpdated = newUpdated; if (getPref(PREFS.autoScroll, '0') === '1') { container.scrollTop = container.scrollHeight; window.scrollTo({ top: document.body.scrollHeight }); } } } catch {} await sleep(5000); } })(); } else { // auto 模式 if (!trySSE()) jsonPollLoop(); } // 事件委派:點擊每個 scratchpad 的「專注」按鈕 container.addEventListener('click', (e) => { const btn = e.target.closest('.btn-focus'); if (!btn) return; const sid = btn.getAttribute('data-scratchpad-id'); if (!sid) return; const url = new URL(window.location.href); url.searchParams.set('sp', sid); window.location.href = url.toString(); }); })();

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pc035860/scratchpad-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server