File size: 16,513 Bytes
d4c5ec7 1cd0943 d4c5ec7 48e919b d4c5ec7 48e919b d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 48e919b d4c5ec7 48e919b d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 d4c5ec7 1cd0943 48e919b 1cd0943 d4c5ec7 1cd0943 48e919b d4c5ec7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | (() => {
'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); });
})();
|