(() => { '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; const chips = () => Array.from(document.querySelectorAll('#chips .chip')); $('clock').textContent = hostClock(); window.setInterval(() => { $('clock').textContent = hostClock(); }, 1000); let vm; let ready = false; let asking = false; let bootTimer; let asked = 0, answered = 0; let llmAnswer = ''; // 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; } function setButtons(enabled, label) { $('askButton').disabled = !enabled; $('askButton').innerHTML = label; chips().forEach((c) => { c.disabled = !enabled; }); } 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(); } // XiaoMao Chinese puts the VGA into a graphics mode and keeps the 80x25 text (char, attribute pairs) in a shadow // buffer at VGA plane offset 0x8000 (patched: A800:0000). Before it loads, the normal text screen is used. function screenLines() { const vga = vm.v86.cpu.devices.vga; if (vga && vga.graphical_mode) { const m = vga.vga_memory; const rows = []; for (let r = 0; r < 25; r++) { const b = new Uint8Array(80); for (let c = 0; c < 80; c++) b[c] = m[0x8000 + (r * 80 + c) * 2]; rows.push(window.Big5.decode(b).replace(/\s+$/, '')); } return rows; } return 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); } // ---- screen presentation ------------------------------------------------------------------------------- // Before XiaoMao loads (first ~2 s) v86 shows an 80x25 DOM text layer; scale it to the card width with CSS. function fitText() { const stage = $('stage'); const text = $('screen').firstElementChild; if (!stage.clientWidth || !text || !text.offsetWidth) return; const k = stage.clientWidth / text.offsetWidth; text.style.transformOrigin = '0 0'; text.style.transform = `scale(${k})`; stage.style.height = `${Math.round(text.offsetHeight * k)}px`; } // With XiaoMao the VGA is in a 640x400 graphics mode and v86 draws it on its own canvas, whose CSS size it sets from // devicePixelRatio. Scaling that canvas with CSS (the first version) broke on HiDPI screens (shown 2x too large and cropped) and // gave uneven strokes at fractional scales. So v86's canvas is hidden and its pixels are presented here, in device pixels: // - integer scale in device pixels: nearest neighbour (perfectly sharp) // - otherwise: nearest-neighbour up to the next integer, then smooth down ("sharp bilinear"): even strokes, no shimmer let zoom = (window.localStorage && localStorage.getItem('dosllm.zoom')) || 'fit'; let aspect43 = !(window.localStorage && localStorage.getItem('dosllm.aspect') === '0'); const view = $('view'); const vctx = view.getContext('2d'); const off = document.createElement('canvas'); const octx = off.getContext('2d'); window.presentInfo = {}; function present() { if (!vm) return; const stage = $('stage'); const host = $('screen'); const src = host.querySelector('canvas'); const vga = vm.v86.cpu.devices.vga; const graphical = !!(vga && vga.graphical_mode && src && src.width > 0 && src.height > 0); host.classList.toggle('hidden', graphical); view.style.display = graphical ? 'block' : 'none'; if (!graphical) { stage.classList.remove('scroll'); fitText(); return; } const dpr = window.devicePixelRatio || 1; const avail = stage.clientWidth; if (!avail) return; const nw = src.width, nh = src.height; let sx; let sy; // CSS pixels per source pixel if (zoom === 'crisp') { sx = Math.max(1, Math.floor(avail * dpr / nw)) / dpr; sy = sx; } // integer device pixels, square else { sx = zoom === 'big' ? 2 : avail / nw; sy = sx * (aspect43 ? (nw / nh) / (4 / 3) : 1); // 640x400 shown as 4:3 like a real VGA monitor: rows 1.2x taller } let pw = Math.round(nw * sx * dpr); let ph = Math.round(nh * sy * dpr); const area = pw * ph; if (area > 16e6) { const f = Math.sqrt(16e6 / area); pw = Math.round(pw * f); ph = Math.round(ph * f); } // keep memory sane if (view.width !== pw || view.height !== ph) { view.width = pw; view.height = ph; } const cw = pw / dpr; const chh = ph / dpr; view.style.width = `${cw}px`; view.style.height = `${chh}px`; stage.style.height = `${Math.round(chh)}px`; stage.classList.toggle('scroll', cw > avail + 1); const kx = Math.ceil(pw / nw); const ky = Math.ceil(ph / nh); if (pw === nw * kx && ph === nh * ky) { vctx.imageSmoothingEnabled = false; vctx.drawImage(src, 0, 0, pw, ph); } else { const ow = nw * Math.min(kx, 8); const oh = nh * Math.min(ky, 8); if (off.width !== ow || off.height !== oh) { off.width = ow; off.height = oh; } octx.imageSmoothingEnabled = false; octx.drawImage(src, 0, 0, ow, oh); vctx.imageSmoothingEnabled = true; vctx.imageSmoothingQuality = 'high'; vctx.drawImage(off, 0, 0, pw, ph); } window.presentInfo = { dpr, avail, native: [nw, nh], css: [Math.round(cw), Math.round(chh)], device: [pw, ph], scaleDevice: [+(pw / nw).toFixed(3), +(ph / nh).toFixed(3)], zoom, aspect43 }; } window.addEventListener('resize', present); // What G.EXE prints: a line "A> ..." followed by indented continuation lines (Chinese text wraps anywhere). const CJK = /[\u3000-\u9fff\uff00-\uffef]/; function dosAnswer() { let out = []; let on = false; for (const l of screenLines()) { 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.reduce((acc, line) => (acc === '' ? line : (CJK.test(acc.slice(-1)) || CJK.test(line[0]) ? acc + line : `${acc} ${line}`)), ''); } // G.EXE can only print what Big5 can show and joins lines with single spaces: apply the same to the LLM's reply. const expectedOnDos = (s) => window.Big5.displayable(s).replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim(); const squash = (s) => s.replace(/\s+/g, ''); // wrapping may add or drop a space at line breaks function watchBoot() { window.clearInterval(bootTimer); bootTimer = 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(bootTimer); ready = true; asking = false; $('netText').textContent = 'DOS NET UP'; $('liveDot').classList.add('on'); $('dosState').textContent = 'NET UP'; $('dosState').classList.add('on'); $('bootTag').textContent = 'FREEDOS READY'; setButtons(true, '▶問 DOS'); $('note').textContent = 'DOS 已取得 DHCP 位址。按「問 DOS」會在 DOS 提示字元輸入 G <問題>,答案會印在 DOS 畫面上。'; } }, 400); } async function boot() { const assets = window.DOSLLM_ASSETS; if (!window.V86 || !assets || !window.GradioBridge || !window.Big5) { $('netText').textContent = 'LOAD ERROR'; $('note').textContent = 'v86 或資產載入失敗,請重新整理頁面。'; return; } if (typeof DecompressionStream !== 'function') { $('netText').textContent = 'UNSUPPORTED'; $('note').textContent = '這個瀏覽器不支援 DecompressionStream,請改用較新的 Chrome / Edge / Safari / Firefox。'; return; } if (!window.Big5.available) logRow('SYS', '這個瀏覽器的 TextDecoder 不支援 big5:只能用英文提問', 'err'); window.GradioBridge.install(); window.GradioBridge.onEvent = (_m, text) => { if (text.startsWith('Gradio answer: ')) { llmAnswer = text.slice('Gradio answer: '.length); logRow('LLM', llmAnswer, 'ok'); } else logRow('BRIDGE', text, 'bridge'); }; const [wasm, bios, vga, img] = await Promise.all(['wasm', 'bios', 'vga', 'img'].map((k) => gunzipBase64(assets[k]))); 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(present, 50)); vm.keyboard_set_status(false); const focus = () => { vm.keyboard_set_status(true); $('stage').classList.add('focus'); }; $('stage').addEventListener('mousedown', focus); $('stage').addEventListener('touchstart', focus, { passive: true }); watchBoot(); } const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('').toUpperCase(); async function ask(fixedQuestion) { if (!ready || asking) return; const host = $('host').value.trim(); const raw = typeof fixedQuestion === 'string' ? fixedQuestion : $('question').value; // DOS shell / JSON metacharacters out, control characters out let text = raw.replace(/[\u0000-\u001f\u007f"\\%&|<>^!]/g, ' ').replace(/\s+/g, ' ').trim(); let enc = window.Big5.encode(text); while (enc.bytes.length > 120) { text = Array.from(text).slice(0, -1).join(''); enc = window.Big5.encode(text); } // G.EXE keeps 120 bytes if (!HOST_RE.test(host)) { toast('主機必須是 *.hf.space 網域'); return; } if (!text) { toast('請輸入問題(中文或英文)'); return; } if (!atPrompt()) { toast('DOS 尚未回到提示字元(上一個程式還在執行)'); return; } const chinese = enc.bytes.some((b) => b >= 0x80); if (chinese && !window.Big5.available) { toast('這個瀏覽器不支援 Big5 轉碼,請用英文提問'); return; } if (enc.lost.length) { toast(`DOS 無法顯示這些字,已改成 ?:${Array.from(new Set(enc.lost)).join('')}(Big5 沒有簡體字)`); logRow('SYS', `無法用 Big5 表示:${enc.lost.join('')}`, 'err'); } $('question').value = text; asking = true; llmAnswer = ''; currentQuestion = window.Big5.displayable(text); asked += 1; $('askCount').textContent = pad3(asked); setButtons(false, '◌DOS 等待回答…'); $('matchTag').textContent = 'WAITING'; $('answerBox').hidden = true; logRow('SYS', `host ${host} ${chinese ? '中文' : 'English'} question "${text}"`); await typeLine(`SET GRADIO_HOST=${host}`); if (chinese) { // The page cannot type Zhuyin, so the Big5 bytes travel as hex in GQ1..GQ4 and G.EXE decodes them. const h = hex(enc.bytes); for (let i = 0; i < 4; i += 1) await typeLine(`SET GQ${i + 1}=${h.slice(i * 60, i * 60 + 60)}`); await typeLine('CLS'); await typeLine('G'); } else { await typeLine('CLS'); await typeLine(`G ${text}`); } const deadline = Date.now() + 190000; // DNS + two HTTP exchanges + model time await sleep(1500); while (Date.now() < deadline && asking && !atPrompt()) await sleep(400); await sleep(500); if (asking) finish(); } function finish() { asking = false; setButtons(true, '▶再問一次'); const dos = dosAnswer(); const expected = expectedOnDos(llmAnswer); if (!dos) { logRow('DOS', 'DOS 畫面上沒有看到答案(G.EXE 可能出錯或逾時,可按「重新開機」再試)', 'err'); $('matchTag').textContent = 'NO ANSWER'; return; } answered += 1; $('ansCount').textContent = pad3(answered); logRow('DOS', `畫面印出:${dos}`, 'ok'); const same = expected !== '' && squash(dos) === squash(expected); const lossy = expected !== '' && expected !== llmAnswer.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim(); $('matchTag').textContent = same ? 'DOS = LLM' : 'DIFFERENT'; logRow('CHECK', same ? `DOS 畫面上的答案與 LLM 回傳的答案相同${lossy ? '(LLM 用了 Big5 沒有的字,DOS 顯示為 ?)' : '(逐字)'}` : `與 LLM 回傳不同。LLM(DOS 能顯示的部分):${expected || '(bridge 沒有擷取到)'}`, same ? 'ok' : 'err'); const box = $('answerBox'); box.hidden = false; box.textContent = ''; for (const [k, v] of [['Q', currentQuestion], ['DOS 印出', dos], ['LLM 回傳', llmAnswer]]) { const b = document.createElement('b'); b.textContent = `${k} `; box.append(b, document.createTextNode(`${v}\n`)); } } // Ctrl+Space toggles XiaoMao's Zhuyin input. The key events need human-like timing: sent in one burst, the BIOS has not // yet seen Ctrl when Space arrives and the toggle is missed. async function toggleIme() { if (!vm || !ready) return; vm.keyboard_set_status(true); $('stage').classList.add('focus'); vm.keyboard_send_scancodes([0x1D]); await sleep(250); vm.keyboard_send_scancodes([0x39]); await sleep(150); vm.keyboard_send_scancodes([0xB9]); await sleep(150); vm.keyboard_send_scancodes([0x9D]); toast('已切換注音/英數(畫面最下列會顯示 [注音] 或 [英數])'); } function reboot() { if (!vm) return; asking = false; ready = false; $('netText').textContent = 'REBOOTING'; $('liveDot').classList.remove('on'); $('dosState').textContent = 'REBOOT'; $('dosState').classList.remove('on'); $('bootTag').textContent = 'REBOOTING FREEDOS'; setButtons(false, '◌DOS 重新開機中…'); vm.restart(); watchBoot(); logRow('SYS', 'DOS 重新開機'); } $('askButton').addEventListener('click', () => ask()); $('question').addEventListener('keydown', (e) => { if (e.key === 'Enter') ask(); }); chips().forEach((c) => c.addEventListener('click', () => ask(c.textContent))); $('rebootButton').addEventListener('click', reboot); $('imeButton').addEventListener('click', toggleIme); window.setInterval(present, 66); // ~15 fps is plenty for a text screen $('zoomSel').value = zoom; $('aspect').checked = aspect43; $('aspectLabel').style.display = zoom === 'crisp' ? 'none' : ''; $('zoomSel').addEventListener('change', () => { zoom = $('zoomSel').value; try { localStorage.setItem('dosllm.zoom', zoom); } catch (e) { /* private mode */ } $('aspectLabel').style.display = zoom === 'crisp' ? 'none' : ''; present(); }); $('aspect').addEventListener('change', () => { aspect43 = $('aspect').checked; try { localStorage.setItem('dosllm.aspect', aspect43 ? '1' : '0'); } catch (e) { /* private mode */ } present(); }); boot().catch((e) => { $('netText').textContent = 'BOOT ERROR'; $('note').textContent = `啟動失敗:${e && e.message ? e.message : e}`; console.error(e); }); })();