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