(() => { 'use strict'; const $ = (id) => document.getElementById(id); const sleep = (ms) => new Promise((r) => window.setTimeout(r, ms)); const pad3 = (n) => String(n).padStart(3, '0'); const hostClock = () => new Date().toLocaleTimeString('zh-TW', { hour12: false }); const HOST_RE = /^[a-z0-9][a-z0-9-]*\.hf\.space$/i; $('clock').textContent = hostClock(); window.setInterval(() => { $('clock').textContent = hostClock(); }, 1000); let vm; let ready = false; let asking = false; let asked = 0, answered = 0; let gradioAnswer = ''; // reply captured by the bridge from the Gradio stream let currentQuestion = ''; let toastTimer; function toast(text) { $('toast').textContent = text; $('toast').classList.add('show'); window.clearTimeout(toastTimer); toastTimer = window.setTimeout(() => $('toast').classList.remove('show'), 2200); } function logRow(src, text, cls = '') { const log = $('log'); const empty = log.querySelector('.log-empty'); if (empty) empty.remove(); const row = document.createElement('div'); row.className = `log-row ${cls}`; for (const [c, t] of [['t', hostClock()], ['src', src], ['msg', text]]) { const s = document.createElement('span'); s.className = c; s.textContent = t; row.append(s); } log.append(row); while (log.children.length > 200) log.firstElementChild.remove(); log.scrollTop = log.scrollHeight; } async function gunzipBase64(b64) { const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); return new Response(new Blob([bin]).stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer(); } const screenLines = () => vm.screen_adapter.get_text_screen().map((l) => l.replace(/\s+$/, '')); const screenText = () => screenLines().join('\n'); const lastLine = () => { const l = screenLines().filter((x) => x.trim() !== ''); return l.length ? l[l.length - 1] : ''; }; const atPrompt = () => /^[A-Z]:\\>$/.test(lastLine().trim()); async function typeLine(text) { await vm.keyboard_send_text(`${text}\n`, 25); await sleep(250); } // The VGA text layer is a fixed-size DOM block; scale it to the card width with CSS. function fitScreen() { const box = $('screen'); const text = box.firstElementChild; const canvas = box.querySelector('canvas'); if (!box.clientWidth) return; const useCanvas = canvas && getComputedStyle(canvas).display !== 'none' && canvas.width > 0; const layer = useCanvas ? canvas : text; const w = useCanvas ? canvas.width : text.offsetWidth, h = useCanvas ? canvas.height : text.offsetHeight; if (!w || !h) return; const k = box.clientWidth / w; layer.style.transformOrigin = '0 0'; layer.style.transform = `scale(${k})`; box.style.height = `${Math.round(h * k)}px`; } window.addEventListener('resize', fitScreen); // What G.EXE prints: lines "A> ..." followed by indented continuation lines. function dosAnswer() { const lines = screenLines(); let out = []; let on = false; for (const l of lines) { if (/^A> /.test(l)) { out = [l.slice(3).trim()]; on = true; } else if (on && /^ {3}\S/.test(l)) out.push(l.trim()); else if (on) on = false; } return out.join(' ').replace(/\s+/g, ' ').trim(); } // G.EXE shows ASCII only and joins lines with single spaces: apply the same to Gradio's reply. const asciiNormalise = (s) => s.replace(/[^\x20-\x7e]/g, (c) => (c === '\n' || c === '\r' ? ' ' : '?')).replace(/\s+/g, ' ').trim(); async function boot() { const assets = window.DOSMQTT_ASSETS; if (!window.V86 || !assets || !window.DOSGRADIO_IMG || !window.GradioBridge) { $('netText').textContent = 'LOAD ERROR'; $('note').textContent = 'v86 或資產載入失敗,請重新整理頁面。'; return; } if (typeof DecompressionStream !== 'function') { $('netText').textContent = 'UNSUPPORTED'; $('note').textContent = '這個瀏覽器不支援 DecompressionStream,請改用較新的瀏覽器。'; return; } window.GradioBridge.install(); window.GradioBridge.onEvent = (_m, text) => { if (text.startsWith('Gradio answer: ')) { gradioAnswer = text.slice('Gradio answer: '.length); logRow('GRADIO', gradioAnswer, 'ok'); } else logRow('BRIDGE', text, 'bridge'); }; const [wasm, bios, vga, img] = await Promise.all([gunzipBase64(assets.wasm), gunzipBase64(assets.bios), gunzipBase64(assets.vga), gunzipBase64(window.DOSGRADIO_IMG)]); const wasmModule = await WebAssembly.compile(wasm); vm = new window.V86({ wasm_fn: async (imports) => (await WebAssembly.instantiate(wasmModule, imports)).exports, memory_size: 16 * 1024 * 1024, vga_memory_size: 2 * 1024 * 1024, screen_container: $('screen'), bios: { buffer: bios }, vga_bios: { buffer: vga }, fda: { buffer: img }, net_device: { type: 'ne2k', relay_url: window.GradioBridge.relayUrl('dos'), dns_method: 'doh' }, autostart: true, disable_speaker: true, }); window.dosVm = vm; // handy for tests: window.dosVm.screen_adapter.get_text_screen() vm.add_listener('screen-set-size', () => window.setTimeout(fitScreen, 50)); vm.keyboard_set_status(false); $('screen').addEventListener('mousedown', () => { vm.keyboard_set_status(true); $('screen').classList.add('focus'); }); $('screen').addEventListener('touchstart', () => { vm.keyboard_set_status(true); $('screen').classList.add('focus'); }, { passive: true }); const timer = window.setInterval(() => { const text = screenText(); const ip = /IPADDR\s+([\d.]+)/.exec(text); if (ip) $('ip').textContent = `IP ${ip[1]}`; if (/TCP\/IP is up/.test(text)) { window.clearInterval(timer); ready = true; $('netText').textContent = 'DOS NET UP'; $('liveDot').classList.add('on'); $('dosState').textContent = 'NET UP'; $('dosState').classList.add('on'); $('bootTag').textContent = 'FREEDOS READY'; $('askButton').disabled = false; $('askButton').innerHTML = '▶問 DOS'; $('note').textContent = 'DOS 已取得 DHCP 位址。按「問 DOS」會在 DOS 提示字元輸入 G <問題>。'; } }, 400); } async function ask() { if (!ready || asking) return; const host = $('host').value.trim(); const question = $('question').value.replace(/[^\x20-\x7e]/g, ' ').replace(/["\\%&|<>^!]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120); if (!HOST_RE.test(host)) { toast('主機必須是 *.hf.space 網域'); return; } if (!question) { toast('請輸入英文問題(ASCII)'); return; } if (!atPrompt()) { toast('DOS 尚未回到提示字元(上一個程式還在執行)'); return; } asking = true; gradioAnswer = ''; currentQuestion = question; asked += 1; $('askCount').textContent = pad3(asked); $('askButton').disabled = true; $('askButton').innerHTML = '◌DOS 等待回答…'; $('matchTag').textContent = 'WAITING'; $('answerBox').hidden = true; logRow('SYS', `host ${host} question "${question}"`); await typeLine(`SET GRADIO_HOST=${host}`); await typeLine('CLS'); await typeLine(`G ${question}`); const deadline = Date.now() + 190000; // DNS + two HTTP exchanges + model time await sleep(1500); while (Date.now() < deadline && !atPrompt()) await sleep(400); await sleep(500); finish(); } function finish() { asking = false; $('askButton').disabled = false; $('askButton').innerHTML = '▶再問一次'; const dos = dosAnswer(); const expected = asciiNormalise(gradioAnswer); if (!dos) { logRow('DOS', 'DOS 畫面上沒有看到答案(G.EXE 可能出錯,請看上方 DOS 畫面)', 'err'); $('matchTag').textContent = 'NO ANSWER'; return; } answered += 1; $('ansCount').textContent = pad3(answered); logRow('DOS', `畫面印出:${dos}`, 'ok'); const same = expected !== '' && dos === expected; $('matchTag').textContent = same ? 'DOS = GRADIO' : 'DIFFERENT'; logRow('CHECK', same ? 'DOS 畫面上的答案與 Gradio 串流回傳的答案逐字相同' : `與 Gradio 回傳不同。Gradio(ASCII 化後):${expected || '(bridge 沒有擷取到)'}`, same ? 'ok' : 'err'); const box = $('answerBox'); box.hidden = false; box.textContent = ''; for (const [k, v] of [['Q', currentQuestion], ['DOS 印出', dos], ['Gradio 回傳', gradioAnswer]]) { const b = document.createElement('b'); b.textContent = `${k} `; box.append(b, document.createTextNode(`${v}\n`)); } } $('askButton').addEventListener('click', ask); $('question').addEventListener('keydown', (e) => { if (e.key === 'Enter') ask(); }); window.setInterval(fitScreen, 500); boot().catch((e) => { $('netText').textContent = 'BOOT ERROR'; $('note').textContent = `啟動失敗:${e && e.message ? e.message : e}`; console.error(e); }); })();