Download app.js from HelloSun/dosllm: direct link, hf CLI and curl.
- Browser
- Download file 16.5 kB
-
https://huggingface.co/spaces/HelloSun/dosllm/resolve/main/app.js
- Command line
-
hf download hf://spaces/HelloSun/dosllm/app.js
-
curl -L -o app.js https://huggingface.co/spaces/HelloSun/dosllm/resolve/main/app.js
16.5 kB
| (() => { | |
| '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, '<span class="button-icon">▶</span>問 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, '<span class="button-icon">◌</span>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, '<span class="button-icon">▶</span>再問一次'); | |
| 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, '<span class="button-icon">◌</span>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); }); | |
| })(); | |