(() => {
'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); });
})();