Spaces:
Sleeping
Sleeping
Download static/solver.html from VeryOnly123/cc-solver: direct link, hf CLI and curl.
- Browser
- Download file 34.9 kB
-
https://huggingface.co/spaces/VeryOnly123/cc-solver/resolve/main/static/solver.html
- Command line
-
hf download hf://spaces/VeryOnly123/cc-solver/static/solver.html
-
curl -L -o solver.html https://huggingface.co/spaces/VeryOnly123/cc-solver/resolve/main/static/solver.html
34.9 kB
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>CC Solver</title> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; color: #333; min-height: 100vh; } | |
| .container { max-width: 960px; margin: 0 auto; padding: 20px; } | |
| h1 { text-align: center; padding: 20px 0 6px; color: #1a1a2e; font-size: 1.8em; } | |
| .subtitle { text-align: center; color: #666; margin-bottom: 24px; font-size: 0.9em; } | |
| .card { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 24px; margin-bottom: 20px; } | |
| .card-title { font-size: 0.95em; font-weight: 700; color: #1a1a2e; margin-bottom: 12px; } | |
| /* Input area */ | |
| .input-mode-toggle { display: flex; gap: 0; margin-bottom: 12px; background: #f0f0f0; border-radius: 8px; overflow: hidden; } | |
| .input-mode-toggle button { flex: 1; padding: 8px 16px; border: none; cursor: pointer; font-size: 13px; font-weight: 600; background: #f0f0f0; color: #666; transition: all 0.15s; } | |
| .input-mode-toggle button.active { background: #4361ee; color: #fff; } | |
| textarea { width: 100%; min-height: 150px; border: 1px solid #ddd; border-radius: 8px; padding: 12px; font-size: 14px; font-family: inherit; resize: vertical; line-height: 1.6; } | |
| textarea:focus { outline: none; border-color: #4361ee; box-shadow: 0 0 0 3px rgba(67,97,238,0.15); } | |
| .file-drop { border: 2px dashed #ccc; border-radius: 8px; padding: 32px 16px; text-align: center; color: #999; cursor: pointer; transition: border-color 0.2s, background 0.2s; } | |
| .file-drop:hover, .file-drop.dragover { border-color: #4361ee; background: #f5f7ff; } | |
| .file-drop input[type="file"] { display: none; } | |
| .file-list { margin-top: 8px; font-size: 13px; } | |
| .file-list .file-item { display: flex; justify-content: space-between; padding: 4px 0; } | |
| .file-list .file-item .remove { color: #e74c3c; cursor: pointer; font-weight: 700; } | |
| .btn-row { display: flex; gap: 12px; margin-top: 12px; } | |
| .btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: all 0.15s; } | |
| .btn-primary { background: #4361ee; color: #fff; } | |
| .btn-primary:hover:not(:disabled) { background: #3a56d4; } | |
| .btn-primary:disabled { background: #a0b0f0; cursor: not-allowed; } | |
| .btn-secondary { background: #e8e8e8; color: #333; } | |
| .btn-secondary:hover { background: #d8d8d8; } | |
| .btn-download { background: #2ecc71; color: #fff; padding: 6px 14px; font-size: 12px; border-radius: 6px; text-decoration: none; } | |
| .btn-download:hover { background: #27ae60; } | |
| /* Config */ | |
| .config-row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; } | |
| .config-row label { font-size: 13px; color: #666; min-width: 90px; } | |
| .config-row input { flex: 1; padding: 6px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; font-family: monospace; } | |
| .config-row button { padding: 6px 14px; border: none; border-radius: 6px; background: #4361ee; color: #fff; font-size: 12px; cursor: pointer; white-space: nowrap; } | |
| /* Task list */ | |
| .task-list { max-height: 400px; overflow-y: auto; } | |
| .task-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #f0f0f0; gap: 8px; } | |
| .task-item:last-child { border-bottom: none; } | |
| .task-name { font-weight: 600; font-size: 14px; } | |
| .task-preview { font-size: 12px; color: #888; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .task-meta { display: flex; align-items: center; gap: 8px; } | |
| .task-delete { cursor: pointer; font-size: 14px; color: #ccc; font-weight: 700; transition: color 0.15s; } | |
| .task-delete:hover { color: #e74c3c; } | |
| .badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600; } | |
| .badge-pending { background: #fff3cd; color: #856404; } | |
| .badge-running { background: #cce5ff; color: #004085; } | |
| .badge-complete { background: #d4edda; color: #155724; } | |
| .badge-error { background: #f8d7da; color: #721c24; } | |
| /* Modal */ | |
| .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 100; align-items: center; justify-content: center; } | |
| .modal-overlay.visible { display: flex; } | |
| .modal { background: #fff; border-radius: 12px; max-width: 800px; width: 90%; max-height: 80vh; overflow-y: auto; padding: 24px; } | |
| .modal h2 { font-size: 1.1em; margin-bottom: 12px; } | |
| .modal .result-content { line-height: 1.7; } | |
| .modal .result-content table { border-collapse: collapse; width: 100%; margin: 12px 0; } | |
| .modal .result-content th, .modal .result-content td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; } | |
| .modal .result-content th { background: #f5f5f5; } | |
| .modal .result-content pre { background: #f8f8f8; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 13px; } | |
| .modal .result-content code { background: #f0f0f0; padding: 1px 4px; border-radius: 3px; font-size: 13px; } | |
| .modal .result-content pre code { background: none; padding: 0; } | |
| .modal .result-content blockquote { border-left: 3px solid #4361ee; padding-left: 12px; color: #555; margin: 8px 0; } | |
| .modal .log-console { background: #1e1e2e; color: #cdd6f4; border-radius: 8px; padding: 12px; max-height: 200px; overflow-y: auto; font-family: monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; } | |
| .error-msg { background: #f8d7da; color: #721c24; padding: 10px; border-radius: 8px; margin-top: 8px; display: none; font-size: 13px; } | |
| .error-msg.visible { display: block; } | |
| /* Per-task progress */ | |
| .task-files { display: flex; gap: 4px; margin-top: 4px; } | |
| .task-file-step { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: #e8e8e8; color: #999; } | |
| .task-file-step.done { background: #d4edda; color: #155724; font-weight: 600; } | |
| .task-file-step.current { background: #cce5ff; color: #004085; font-weight: 600; animation: pulse 1.5s infinite; } | |
| /* Workspace file tabs */ | |
| .ws-tab:hover { background: #e0e0e0 ; } | |
| /* Math formulas */ | |
| .math-display { overflow-x: auto; margin: 12px 0; padding: 8px 0; text-align: center; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>CC Solver</h1> | |
| <p class="subtitle">Multi-agent physics problem solver</p> | |
| <!-- Config --> | |
| <div class="card"> | |
| <div class="card-title">Solver Config</div> | |
| <div class="config-row"> | |
| <label>Repo URL</label> | |
| <input type="text" id="solver-url" placeholder="https://github.com/Usami-Mira/CC_Integrater/raw/refs/heads/main/"> | |
| <button onclick="updateConfig()">Update</button> | |
| </div> | |
| <div class="config-row"> | |
| <label>Token</label> | |
| <input type="password" id="solver-token" placeholder="Enter password"> | |
| </div> | |
| <p style="font-size:12px;color:#888;margin-top:8px;">Model 和 timeout 由仓库 config.json 控制。</p> | |
| </div> | |
| <!-- Input --> | |
| <div class="card"> | |
| <div class="card-title">New Task</div> | |
| <div class="input-mode-toggle"> | |
| <button id="mode-text" class="active" onclick="setInputMode('text')">Text Input</button> | |
| <button id="mode-file" onclick="setInputMode('file')">Upload Files</button> | |
| </div> | |
| <!-- Config override --> | |
| <details style="margin-bottom:12px;"> | |
| <summary style="cursor:pointer;font-size:13px;font-weight:600;color:#666;">Custom Config (config.json)</summary> | |
| <textarea id="config-input" placeholder='Loading config...' style="min-height:80px;margin-top:8px;font-family:monospace;font-size:13px;"></textarea> | |
| </details> | |
| <!-- Private mode --> | |
| <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;"> | |
| <label style="font-size:13px;font-weight:600;color:#666;cursor:pointer;display:flex;align-items:center;gap:6px;"> | |
| <input type="checkbox" id="private-mode" style="width:16px;height:16px;cursor:pointer;"> | |
| Private Mode | |
| </label> | |
| <span id="secret-display" style="font-size:12px;color:#aaa;font-family:monospace;display:none;"></span> | |
| </div> | |
| <div id="input-text-area"> | |
| <textarea id="problem-input" placeholder="Paste your physics problem here..."></textarea> | |
| <div class="btn-row"> | |
| <button class="btn btn-primary" id="submit-btn" onclick="submitText()">Solve</button> | |
| </div> | |
| </div> | |
| <div id="input-file-area" style="display:none;"> | |
| <div class="file-drop" id="file-drop" onclick="document.getElementById('file-input').click()"> | |
| <p>Click or drag <strong>.md</strong> files here</p> | |
| <input type="file" id="file-input" accept=".md" multiple> | |
| </div> | |
| <div class="file-list" id="file-list"></div> | |
| <div class="btn-row"> | |
| <button class="btn btn-primary" id="submit-files-btn" onclick="submitFiles()">Solve All</button> | |
| </div> | |
| </div> | |
| <div class="error-msg" id="error-msg"></div> | |
| </div> | |
| <!-- Tasks --> | |
| <div class="card"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"> | |
| <div class="card-title" style="margin-bottom:0;">Tasks</div> | |
| <div> | |
| <a class="btn-download" id="cleanup-btn" style="display:none;margin-right:8px;cursor:pointer;" onclick="cleanupTasks()">Clear Done</a> | |
| <a class="btn-download" id="download-all-btn" style="display:none;" onclick="downloadAll()">Download All</a> | |
| </div> | |
| </div> | |
| <div class="task-list" id="task-list"> | |
| <p style="color:#999;font-size:13px;">No tasks yet.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Task detail modal --> | |
| <div class="modal-overlay" id="modal-overlay" onclick="closeModal(event)"> | |
| <div class="modal" id="modal-content"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"> | |
| <h2 id="modal-title" style="margin-bottom:0;">Task Detail</h2> | |
| <div> | |
| <button id="modal-refresh" style="padding:6px 10px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;margin-right:8px;" title="Refresh" onclick="refreshModalContent()">Refresh</button> | |
| <a class="btn-download" id="modal-download" style="display:none;margin-right:8px;" onclick="">Download</a> | |
| <button class="btn btn-secondary" onclick="closeModalDirect()" style="padding:6px 14px;font-size:12px;">Close</button> | |
| </div> | |
| </div> | |
| <div id="modal-body"></div> | |
| </div> | |
| </div> | |
| <script> | |
| let inputMode = 'text'; | |
| let pendingFiles = []; | |
| let pollTimers = {}; | |
| // Configure marked to preserve math delimiters | |
| marked.setOptions({ | |
| breaks: true, | |
| gfm: true, | |
| }); | |
| function renderMarkdownWithMath(md) { | |
| // Protect math delimiters from marked escaping | |
| const mathBlocks = []; | |
| const protectedMd = md | |
| .replace(/\$\$([\s\S]+?)\$\$/g, (m) => { | |
| mathBlocks.push({ type: 'block', content: m.slice(2, -2) }); | |
| return `%%MATH_BLOCK_${mathBlocks.length - 1}%%`; | |
| }) | |
| .replace(/\$([^\$\n]+?)\$/g, (m) => { | |
| mathBlocks.push({ type: 'inline', content: m.slice(1, -1) }); | |
| return `%%MATH_BLOCK_${mathBlocks.length - 1}%%`; | |
| }); | |
| let html = marked.parse(protectedMd); | |
| // Restore math blocks | |
| html = html.replace(/%%MATH_BLOCK_(\d+)%%/g, (_, idx) => { | |
| const block = mathBlocks[parseInt(idx)]; | |
| try { | |
| if (block.type === 'block') { | |
| return `<div class="math-display">${katex.renderToString(block.content.trim(), { displayMode: true, throwOnError: false })}</div>`; | |
| } | |
| return katex.renderToString(block.content, { displayMode: false, throwOnError: false }); | |
| } catch (e) { | |
| return block.type === 'block' ? `$$${block.content}$$` : `$${block.content}$`; | |
| } | |
| }); | |
| return html; | |
| } | |
| function getToken() { | |
| const val = document.getElementById('solver-token').value.trim(); | |
| return val; | |
| } | |
| function getConfigOverride() { | |
| const val = document.getElementById('config-input').value.trim(); | |
| if (!val) return null; | |
| try { return JSON.parse(val); } | |
| catch (e) { showError('Invalid config JSON: ' + e.message); return null; } | |
| } | |
| // Secret key for private mode | |
| function getSecretKey() { | |
| let key = localStorage.getItem('secret_key'); | |
| if (!key) { | |
| key = generateKey(); | |
| localStorage.setItem('secret_key', key); | |
| } | |
| return key; | |
| } | |
| function generateKey() { | |
| const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; | |
| let result = ''; | |
| const buf = new Uint8Array(16); | |
| crypto.getRandomValues(buf); | |
| for (let i = 0; i < 16; i++) { | |
| result += chars[buf[i] % chars.length]; | |
| } | |
| return result; | |
| } | |
| function api(url, opts = {}) { | |
| const token = getToken(); | |
| const sk = getSecretKey(); | |
| const separator = url.includes('?') ? '&' : '?'; | |
| let sep = ''; | |
| if (token) sep += `token=${encodeURIComponent(token)}`; | |
| if (sk) { | |
| sep += `${sep ? '&' : ''}key=${encodeURIComponent(sk)}`; | |
| } | |
| return fetch(url + (sep ? separator + sep : ''), opts); | |
| } | |
| function setInputMode(mode) { | |
| inputMode = mode; | |
| document.getElementById('mode-text').classList.toggle('active', mode === 'text'); | |
| document.getElementById('mode-file').classList.toggle('active', mode === 'file'); | |
| document.getElementById('input-text-area').style.display = mode === 'text' ? 'block' : 'none'; | |
| document.getElementById('input-file-area').style.display = mode === 'file' ? 'block' : 'none'; | |
| } | |
| // File handling | |
| const fileInput = document.getElementById('file-input'); | |
| const fileDrop = document.getElementById('file-drop'); | |
| fileInput.addEventListener('change', (e) => addFiles(e.target.files)); | |
| fileDrop.addEventListener('dragover', (e) => { e.preventDefault(); fileDrop.classList.add('dragover'); }); | |
| fileDrop.addEventListener('dragleave', () => fileDrop.classList.remove('dragover')); | |
| fileDrop.addEventListener('drop', (e) => { e.preventDefault(); fileDrop.classList.remove('dragover'); addFiles(e.dataTransfer.files); }); | |
| function addFiles(files) { | |
| for (const f of files) { | |
| if (f.name.endsWith('.md') && !pendingFiles.find(x => x.name === f.name)) { | |
| pendingFiles.push(f); | |
| } | |
| } | |
| renderFileList(); | |
| } | |
| function removeFile(idx) { | |
| pendingFiles.splice(idx, 1); | |
| renderFileList(); | |
| } | |
| function renderFileList() { | |
| const el = document.getElementById('file-list'); | |
| if (pendingFiles.length === 0) { el.innerHTML = ''; return; } | |
| el.innerHTML = pendingFiles.map((f, i) => | |
| `<div class="file-item"><span>${f.name} (${(f.size/1024).toFixed(1)} KB)</span><span class="remove" onclick="removeFile(${i})">×</span></div>` | |
| ).join(''); | |
| } | |
| function showError(msg) { | |
| const el = document.getElementById('error-msg'); | |
| el.textContent = msg; | |
| el.classList.add('visible'); | |
| setTimeout(() => el.classList.remove('visible'), 5000); | |
| } | |
| function hideError() { document.getElementById('error-msg').classList.remove('visible'); } | |
| // Submit text problem | |
| async function submitText() { | |
| const text = document.getElementById('problem-input').value.trim(); | |
| if (!text) return; | |
| hideError(); | |
| try { | |
| const body = { problem: text }; | |
| const cfg = getConfigOverride(); | |
| if (cfg) body.config = cfg; | |
| if (document.getElementById('private-mode').checked) { | |
| body.secret_key = getSecretKey(); | |
| } | |
| const resp = await api('/api/submit', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify(body) | |
| }); | |
| const data = await resp.json(); | |
| if (data.error) { showError(data.error); return; } | |
| document.getElementById('problem-input').value = ''; | |
| loadTasks(); | |
| } catch (e) { showError('Network error: ' + e.message); } | |
| } | |
| // Submit files | |
| async function submitFiles() { | |
| if (pendingFiles.length === 0) { showError('No files selected'); return; } | |
| hideError(); | |
| const fd = new FormData(); | |
| pendingFiles.forEach(f => fd.append('files', f)); | |
| const cfg = getConfigOverride(); | |
| if (cfg) fd.append('config', JSON.stringify(cfg)); | |
| if (document.getElementById('private-mode').checked) { | |
| fd.append('secret_key', getSecretKey()); | |
| } | |
| try { | |
| const token = getToken(); | |
| const resp = await api('/api/submit-file', { method: 'POST', body: fd }); | |
| const data = await resp.json(); | |
| if (data.error) { showError(data.error); return; } | |
| pendingFiles = []; | |
| renderFileList(); | |
| fileInput.value = ''; | |
| loadTasks(); | |
| } catch (e) { showError('Network error: ' + e.message); } | |
| } | |
| const DEFAULT_URL = 'https://github.com/Usami-Mira/CC_Integrater/raw/refs/heads/main/'; | |
| // Load config | |
| const savedUrl = localStorage.getItem('solver_url'); | |
| document.getElementById('solver-url').value = savedUrl || DEFAULT_URL; | |
| api('/api/config') | |
| .then(r => r.json()) | |
| .then(data => { | |
| if (data.solver_url) { | |
| document.getElementById('solver-url').value = data.solver_url; | |
| localStorage.setItem('solver_url', data.solver_url); | |
| } | |
| if (data.default_config && Object.keys(data.default_config).length > 0) { | |
| document.getElementById('config-input').value = JSON.stringify(data.default_config, null, 2); | |
| } | |
| }) | |
| .catch(() => {}); | |
| // Load saved token | |
| const savedToken = localStorage.getItem('solver_token'); | |
| if (savedToken) { | |
| document.getElementById('solver-token').value = savedToken; | |
| } | |
| document.getElementById('solver-token').addEventListener('change', (e) => { | |
| localStorage.setItem('solver_token', e.target.value); | |
| }); | |
| async function updateConfig() { | |
| const url = document.getElementById('solver-url').value.trim(); | |
| if (!url) return; | |
| localStorage.setItem('solver_url', url); | |
| const btn = document.querySelector('.config-row button'); | |
| btn.textContent = 'Fetching...'; | |
| btn.disabled = true; | |
| try { | |
| const resp = await api('/api/config', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({solver_url: url}) | |
| }); | |
| const data = await resp.json(); | |
| if (data.status === 'ok') { | |
| alert('Solver files updated successfully!'); | |
| } else { | |
| alert('Update failed: ' + (data.error || 'unknown')); | |
| } | |
| } catch (e) { alert('Network error'); } | |
| btn.textContent = 'Update'; | |
| btn.disabled = false; | |
| } | |
| // Task list | |
| function renderTaskProgress(filesDone, status) { | |
| const steps = (filesDone || []).map(f => { | |
| const step = typeof f === 'string' ? f : f.step; | |
| const st = typeof f === 'string' ? 'history' : f.status; | |
| const cls = st === 'current' ? 'current' : 'done'; | |
| return `<span class="task-file-step ${cls}">${step}</span>`; | |
| }); | |
| if (status === 'running' || status === 'pending') { | |
| steps.push(`<span class="task-file-step current">running…</span>`); | |
| } | |
| return steps.join(''); | |
| } | |
| async function loadTasks() { | |
| try { | |
| const resp = await api('/api/tasks'); | |
| const data = await resp.json(); | |
| const el = document.getElementById('task-list'); | |
| const downloadAll = document.getElementById('download-all-btn'); | |
| if (!data.tasks || data.tasks.length === 0) { | |
| el.innerHTML = '<p style="color:#999;font-size:13px;">No tasks yet.</p>'; | |
| downloadAll.style.display = 'none'; | |
| document.getElementById('cleanup-btn').style.display = 'none'; | |
| return; | |
| } | |
| const hasComplete = data.tasks.some(t => t.status === 'complete'); | |
| downloadAll.style.display = hasComplete ? 'inline-block' : 'none'; | |
| const hasCleanup = data.tasks.some(t => t.status === 'complete' || t.status === 'error'); | |
| document.getElementById('cleanup-btn').style.display = hasCleanup ? 'inline-block' : 'none'; | |
| const token = getToken(); | |
| const tokenSep = token ? `?token=${encodeURIComponent(token)}&` : '?'; | |
| el.innerHTML = data.tasks.map(t => { | |
| const badgeClass = 'badge-' + t.status; | |
| const progressHtml = renderTaskProgress(t.files_done || [], t.status); | |
| const lockIcon = t.is_private ? '<span style="font-size:12px;color:#e67e22;" title="Private task">🔒</span>' : ''; | |
| const deleteBtn = t.status !== 'running' | |
| ? `<span class="task-delete" onclick="event.stopPropagation();deleteTask('${t.task_id}')" title="Delete">✕</span>` | |
| : ''; | |
| const sk = getSecretKey(); | |
| const downloadHref = `/api/download/${t.task_id}?key=${encodeURIComponent(sk)}` + (token ? `&token=${encodeURIComponent(token)}` : ''); | |
| return `<div class="task-item" onclick="openTask('${t.task_id}')"> | |
| <div style="flex:1;min-width:0;"> | |
| <div class="task-name">${t.name || t.task_id} ${lockIcon}</div> | |
| <div style="font-size:11px;color:#aaa;font-family:monospace;">${t.task_id}</div> | |
| <div class="task-preview">${t.problem_preview || ''}</div> | |
| <div class="task-files">${progressHtml}</div> | |
| </div> | |
| <div class="task-meta"> | |
| <span class="badge ${badgeClass}">${t.status}</span> | |
| ${t.status === 'complete' ? `<a class="btn-download" href="${downloadHref}" onclick="event.stopPropagation()">↓ Download</a>` : ''} | |
| ${deleteBtn} | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| } catch (e) {} | |
| } | |
| // Task detail modal | |
| async function openTask(taskId) { | |
| const overlay = document.getElementById('modal-overlay'); | |
| const body = document.getElementById('modal-body'); | |
| const title = document.getElementById('modal-title'); | |
| const downloadBtn = document.getElementById('modal-download'); | |
| title.textContent = 'Loading...'; | |
| downloadBtn.style.display = 'none'; | |
| body.innerHTML = '<p style="color:#888;">Loading task info...</p>'; | |
| overlay.classList.add('visible'); | |
| try { | |
| const resp = await api('/api/status/' + taskId); | |
| if (!resp.ok) { | |
| const errData = await resp.json().catch(() => null); | |
| body.innerHTML = `<p style="color:#888;">Failed to load task: HTTP ${resp.status}${errData?.error ? ' — ' + errData.error : ''}</p>`; | |
| return; | |
| } | |
| const t = await resp.json(); | |
| if (!t || !t.status) { | |
| body.innerHTML = `<p style="color:#888;">Invalid response from server</p>`; | |
| return; | |
| } | |
| title.textContent = t.name || taskId; | |
| const progressHtml = renderTaskProgress(t.files_done || [], t.status); | |
| if (t.status === 'complete') { | |
| const resResp = await api('/api/result/' + taskId); | |
| const resData = await resResp.json(); | |
| const logResp = await api('/api/logs/' + taskId + '?lines=100'); | |
| const logData = await logResp.json(); | |
| const logHtml = (logData.logs || []).length > 0 | |
| ? `<details style="margin-top:16px;"><summary style="cursor:pointer;font-size:13px;font-weight:600;color:#666;">Logs</summary><div class="log-console">${(logData.logs || []).map(l => escapeHtml(l)).join('\n')}</div></details>` | |
| : ''; | |
| if (resData.content) { | |
| body.innerHTML = `<div class="task-files" style="margin-bottom:12px;">${progressHtml}</div><div class="result-content">${renderMarkdownWithMath(resData.content)}</div>${logHtml}`; | |
| } else { | |
| body.innerHTML = `<div class="task-files" style="margin-bottom:12px;">${progressHtml}</div>${logHtml}<p style="color:#888;">Result file not found. <a href="/api/download/${taskId}">Download workspace</a> instead.</p>`; | |
| } | |
| const tkn = getToken(); | |
| downloadBtn.href = `/api/download/${taskId}`; | |
| downloadBtn.style.display = 'inline-block'; | |
| } else if (t.status === 'error') { | |
| const logResp = await api('/api/logs/' + taskId + '?lines=100'); | |
| const logData = await logResp.json(); | |
| body.innerHTML = `<div class="task-files" style="margin-bottom:12px;">${progressHtml}</div><div class="error-msg visible">${t.error || 'Task failed'}</div><div class="log-console" style="margin-top:12px;">${(logData.logs || []).map(l => escapeHtml(l)).join('\n')}</div>`; | |
| } else { | |
| // Running/pending: show progress + logs + workspace files | |
| const logResp = await api('/api/logs/' + taskId + '?lines=50'); | |
| const logData = await logResp.json(); | |
| const wsFiles = await loadWorkspaceFiles(taskId); | |
| body.innerHTML = `<div class="task-files" style="margin-bottom:12px;">${progressHtml}</div>${renderWorkspaceFiles(taskId, wsFiles)}<div class="log-console" style="margin-top:12px;">${(logData.logs || []).map(l => escapeHtml(l)).join('\n')}</div>`; | |
| // Auto-load first file | |
| if (wsFiles.length > 0) { | |
| showWorkspaceFile(taskId, wsFiles[0], 'ws-tabs-' + taskId, 'ws-content-' + taskId); | |
| } | |
| } | |
| // Start polling if running | |
| if (t.status === 'running' || t.status === 'pending') { | |
| startTaskPoll(taskId); | |
| } | |
| } catch (e) { | |
| body.innerHTML = `<p style="color:#888;">Failed to load task: ${e.message}</p>`; | |
| console.error('openTask error:', e); | |
| } | |
| } | |
| function startTaskPoll(taskId) { | |
| if (pollTimers[taskId]) clearInterval(pollTimers[taskId]); | |
| pollTimers[taskId] = setInterval(async () => { | |
| try { | |
| const resp = await api('/api/status/' + taskId); | |
| const t = await resp.json(); | |
| if (t.status === 'complete') { | |
| clearInterval(pollTimers[taskId]); | |
| delete pollTimers[taskId]; | |
| openTask(taskId); | |
| loadTasks(); | |
| } else if (t.status === 'error') { | |
| clearInterval(pollTimers[taskId]); | |
| delete pollTimers[taskId]; | |
| loadTasks(); | |
| } else { | |
| // Running/pending: only refresh task list, leave modal alone | |
| loadTasks(); | |
| } | |
| } catch (e) {} | |
| }, 10000); | |
| } | |
| // Workspace files for running tasks | |
| async function loadWorkspaceFiles(taskId) { | |
| try { | |
| const resp = await api('/api/workspace/' + taskId + '/files'); | |
| const data = await resp.json(); | |
| return data.files || []; | |
| } catch (e) { return []; } | |
| } | |
| async function loadWorkspaceFile(taskId, filename) { | |
| try { | |
| const resp = await api('/api/workspace/' + taskId + '/' + filename); | |
| const data = await resp.json(); | |
| return data.content || null; | |
| } catch (e) { return null; } | |
| } | |
| function renderWorkspaceFiles(taskId, files) { | |
| if (!files || files.length === 0) return ''; | |
| const tabsId = 'ws-tabs-' + taskId; | |
| const contentId = 'ws-content-' + taskId; | |
| const firstFile = files[0]; | |
| const tabs = files.map(f => | |
| `<button class="ws-tab" data-task="${taskId}" data-file="${f}" onclick="showWorkspaceFile('${taskId}','${f}','${tabsId}','${contentId}')" style="padding:4px 10px;border:1px solid #ddd;border-radius:4px 4px 0 0;background:${f === firstFile ? '#4361ee' : '#f0f0f0'};color:${f === firstFile ? '#fff' : '#666'};border-color:${f === firstFile ? '#4361ee' : '#ddd'};cursor:pointer;font-size:12px;font-family:monospace;">${f}</button>` | |
| ).join(''); | |
| return `<div id="${tabsId}" style="display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap;">${tabs}</div><div id="${contentId}" style="background:#1e1e2e;color:#cdd6f4;border-radius:8px;padding:12px;max-height:300px;overflow-y:auto;font-family:monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;">Loading...</div>`; | |
| } | |
| async function showWorkspaceFile(taskId, filename, tabsId, contentId) { | |
| const content = await loadWorkspaceFile(taskId, filename); | |
| const el = document.getElementById(contentId); | |
| if (el) el.textContent = content || 'File not found'; | |
| // Highlight active tab | |
| document.querySelectorAll(`#${tabsId} .ws-tab`).forEach(btn => { | |
| btn.style.background = btn.dataset.file === filename ? '#4361ee' : '#f0f0f0'; | |
| btn.style.color = btn.dataset.file === filename ? '#fff' : '#666'; | |
| btn.style.borderColor = btn.dataset.file === filename ? '#4361ee' : '#ddd'; | |
| }); | |
| } | |
| // Delete task | |
| async function deleteTask(taskId) { | |
| if (!confirm('Delete this task? This cannot be undone.')) return; | |
| try { | |
| const resp = await api(`/api/task/${taskId}`, { method: 'DELETE' }); | |
| const data = await resp.json(); | |
| if (data.error) { showError(data.error); return; } | |
| loadTasks(); | |
| } catch (e) { showError('Network error: ' + e.message); } | |
| } | |
| // Cleanup completed/errored tasks | |
| async function cleanupTasks() { | |
| if (!confirm('Clear all completed and errored tasks? This cannot be undone.')) return; | |
| try { | |
| const resp = await api('/api/tasks/cleanup', { method: 'POST' }); | |
| const data = await resp.json(); | |
| loadTasks(); | |
| } catch (e) { showError('Network error: ' + e.message); } | |
| } | |
| function closeModal(e) { | |
| if (e.target === document.getElementById('modal-overlay')) { | |
| closeModalDirect(); | |
| } | |
| } | |
| function closeModalDirect() { | |
| document.getElementById('modal-overlay').classList.remove('visible'); | |
| Object.values(pollTimers).forEach(t => clearInterval(t)); | |
| pollTimers = {}; | |
| } | |
| async function refreshModalContent() { | |
| const taskId = document.getElementById('modal-download').href?.split('/').pop(); | |
| if (!taskId) return; | |
| // Remember which file user was viewing | |
| const activeTab = document.querySelector('.ws-tab[style*="#4361ee"]'); | |
| const currentFile = activeTab ? activeTab.dataset.file : null; | |
| const resp = await api('/api/status/' + taskId); | |
| if (!resp.ok) return; | |
| const t = await resp.json(); | |
| document.getElementById('modal-title').textContent = t.name || taskId; | |
| const progressHtml = renderTaskProgress(t.files_done || [], t.status); | |
| if (t.status === 'complete') { | |
| const resResp = await api('/api/result/' + taskId); | |
| const resData = await resResp.json(); | |
| const logResp = await api('/api/logs/' + taskId + '?lines=100'); | |
| const logData = await logResp.json(); | |
| const logHtml = (logData.logs || []).length > 0 | |
| ? `<details style="margin-top:16px;"><summary style="cursor:pointer;font-size:13px;font-weight:600;color:#666;">Logs</summary><div class="log-console">${(logData.logs || []).map(l => escapeHtml(l)).join('\n')}</div></details>` | |
| : ''; | |
| if (resData.content) { | |
| document.getElementById('modal-body').innerHTML = `<div class="task-files" style="margin-bottom:12px;">${progressHtml}</div><div class="result-content">${renderMarkdownWithMath(resData.content)}</div>${logHtml}`; | |
| } else { | |
| document.getElementById('modal-body').innerHTML = `<div class="task-files" style="margin-bottom:12px;">${progressHtml}</div>${logHtml}<p style="color:#888;">Result file not found. <a href="/api/download/${taskId}">Download workspace</a> instead.</p>`; | |
| } | |
| const sk = getSecretKey(); | |
| const token = getToken(); | |
| document.getElementById('modal-download').href = `/api/download/${taskId}?key=${encodeURIComponent(sk)}${token ? '&token=' + encodeURIComponent(token) : ''}`; | |
| document.getElementById('modal-download').style.display = 'inline-block'; | |
| } else if (t.status === 'error') { | |
| const logResp = await api('/api/logs/' + taskId + '?lines=100'); | |
| const logData = await logResp.json(); | |
| document.getElementById('modal-body').innerHTML = `<div class="task-files" style="margin-bottom:12px;">${progressHtml}</div><div class="error-msg visible">${t.error || 'Task failed'}</div><div class="log-console" style="margin-top:12px;">${(logData.logs || []).map(l => escapeHtml(l)).join('\n')}</div>`; | |
| } else { | |
| const logResp = await api('/api/logs/' + taskId + '?lines=50'); | |
| const logData = await logResp.json(); | |
| const wsFiles = await loadWorkspaceFiles(taskId); | |
| document.getElementById('modal-body').innerHTML = `<div class="task-files" style="margin-bottom:12px;">${progressHtml}</div>${renderWorkspaceFiles(taskId, wsFiles)}<div class="log-console" style="margin-top:12px;">${(logData.logs || []).map(l => escapeHtml(l)).join('\n')}</div>`; | |
| // Restore previously viewed file | |
| const fileToLoad = wsFiles.includes(currentFile) ? currentFile : wsFiles[0]; | |
| if (fileToLoad) { | |
| showWorkspaceFile(taskId, fileToLoad, 'ws-tabs-' + taskId, 'ws-content-' + taskId); | |
| } | |
| } | |
| } | |
| function downloadAll() { | |
| const token = getToken(); | |
| const sk = getSecretKey(); | |
| const sep = token ? '?' : '?'; | |
| window.location.href = `/api/download-all?${sep}key=${encodeURIComponent(sk)}${token ? '&token=' + encodeURIComponent(token) : ''}`; | |
| } | |
| function escapeHtml(s) { | |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| // Private mode UI | |
| const privateModeCheckbox = document.getElementById('private-mode'); | |
| const secretDisplay = document.getElementById('secret-display'); | |
| if (privateModeCheckbox) { | |
| privateModeCheckbox.addEventListener('change', () => { | |
| secretDisplay.style.display = privateModeCheckbox.checked ? 'inline' : 'none'; | |
| secretDisplay.textContent = privateModeCheckbox.checked ? `Key: ${getSecretKey()}` : ''; | |
| }); | |
| } | |
| // Initial load | |
| loadTasks(); | |
| setInterval(loadTasks, 10000); | |
| </script> | |
| </body> | |
| </html> | |