dosllm / app.js
HelloSun's picture
Fix DOS screen on HiDPI (was 2x too large and cropped): device-pixel presenter, sharp scaling, 4:3, zoom modes
48e919b verified
Raw History Blame Contribute Delete
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); });
})();