@salism3/dsh-latex-editor 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,994 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, statSync, readFileSync, writeFileSync, createReadStream, mkdirSync, readdirSync, rmSync } from 'node:fs';
4
+ import { createServer as createHttpServer, request as httpRequest } from 'node:http';
5
+ import { connect as netConnect } from 'node:net';
6
+ import { networkInterfaces, tmpdir } from 'node:os';
7
+ import { resolve, join, dirname } from 'node:path';
8
+
9
+ export const name = 'dsh-latex-editor';
10
+ export const inject = ['webServer', 'systemPrompt', 'tools', 'workingDirectory', 'workspaceRegistry'];
11
+
12
+ const MIKTEX_BIN_DIR = process.env.LATEX_BIN_DIR || process.env.MIKTEX_BIN_DIR || 'D:\\miktex\\miktex\\bin\\x64';
13
+
14
+ let isCompiling = false;
15
+ let lastLog = '';
16
+ let lastSuccess = false;
17
+ let lastCompileTime = 0;
18
+
19
+ function resolveWorkspace(ctx) {
20
+ // 1. Cek workspaceRegistry
21
+ try {
22
+ if (ctx.workspaceRegistry && typeof ctx.workspaceRegistry.list === 'function') {
23
+ const list = ctx.workspaceRegistry.list();
24
+ if (list && list.length > 0) {
25
+ for (const w of list) {
26
+ if (w.path && existsSync(join(w.path, 'main.tex'))) {
27
+ return w.path;
28
+ }
29
+ }
30
+ if (list[0]?.path && existsSync(list[0].path)) {
31
+ return list[0].path;
32
+ }
33
+ }
34
+ }
35
+ } catch {}
36
+
37
+ // 2. Cek workingDirectory default
38
+ try {
39
+ if (ctx.workingDirectory && typeof ctx.workingDirectory.defaultDirectory === 'string') {
40
+ const dir = ctx.workingDirectory.defaultDirectory;
41
+ if (existsSync(join(dir, 'main.tex'))) {
42
+ return dir;
43
+ }
44
+ }
45
+ } catch {}
46
+
47
+ // 3. Cek process.cwd()
48
+ const cwd = process.cwd();
49
+ if (existsSync(join(cwd, 'main.tex'))) {
50
+ return cwd;
51
+ }
52
+
53
+ // 4. Cek fallback default-workspace lokal
54
+ const localDefault = join(cwd, 'default-workspace');
55
+ if (existsSync(localDefault)) {
56
+ return localDefault;
57
+ }
58
+
59
+ return cwd;
60
+ }
61
+
62
+ // Workspace aktif: klien mengirim path workspace sesi yang sedang dibuka.
63
+ // Hanya path yang terdaftar sebagai workspace DSH yang diterima.
64
+ function normalizePathForMatch(value) {
65
+ return String(value)
66
+ .trim()
67
+ .replace(/\//g, '\\')
68
+ .replace(/\\{2,}/g, '\\')
69
+ .replace(/\\+$/u, '')
70
+ .toLowerCase();
71
+ }
72
+
73
+ function pickWorkspace(ctx, requested) {
74
+ const fallback = resolveWorkspace(ctx);
75
+ if (typeof requested !== 'string' || requested.trim() === '') return fallback;
76
+
77
+ try {
78
+ const registry = ctx.workspaceRegistry;
79
+ const list = registry && typeof registry.list === 'function' ? registry.list() : [];
80
+ const target = normalizePathForMatch(requested);
81
+ const match = list.find((item) => item && typeof item.path === 'string' && normalizePathForMatch(item.path) === target);
82
+ if (match && existsSync(match.path)) return match.path;
83
+ } catch {}
84
+
85
+ return fallback;
86
+ }
87
+
88
+ function readRequestedWorkspace(req, body) {
89
+ if (body && typeof body.ws === 'string') return body.ws;
90
+ try {
91
+ const url = new URL(req.url || '/', 'http://localhost');
92
+ const value = url.searchParams.get('ws');
93
+ if (value) return value;
94
+ } catch {}
95
+ return undefined;
96
+ }
97
+
98
+ function resolveBinary(engine = 'xelatex') {
99
+ const binaryName = engine === 'pdflatex' ? 'pdflatex.exe' : 'xelatex.exe';
100
+ const customPath = join(MIKTEX_BIN_DIR, binaryName);
101
+ if (existsSync(customPath)) {
102
+ return customPath;
103
+ }
104
+ return binaryName;
105
+ }
106
+
107
+ function resolveTool(binaryName) {
108
+ const customPath = join(MIKTEX_BIN_DIR, binaryName);
109
+ return existsSync(customPath) ? customPath : binaryName;
110
+ }
111
+
112
+ function miktexEnv() {
113
+ return { ...process.env, PATH: `${MIKTEX_BIN_DIR};${process.env.PATH || ''}` };
114
+ }
115
+
116
+ // ===========================================================================
117
+ // Cache raster halaman PDF (dipakai viewer internal, bukan plugin PDF browser)
118
+ // ===========================================================================
119
+ const PAGE_DPI = 150;
120
+
121
+ const pdfMetaCache = new Map();
122
+ const pageRenderJobs = new Map();
123
+
124
+ function pageCacheRoot(workspaceDir) {
125
+ const hash = createHash('sha1').update(String(workspaceDir).toLowerCase()).digest('hex').slice(0, 16);
126
+ return join(tmpdir(), 'dsh-latex-pages', hash);
127
+ }
128
+
129
+ function prunePageCache(workspaceDir, keepDirName) {
130
+ try {
131
+ const root = pageCacheRoot(workspaceDir);
132
+ if (!existsSync(root)) return;
133
+ for (const name of readdirSync(root)) {
134
+ if (name === keepDirName) continue;
135
+ try {
136
+ rmSync(join(root, name), { recursive: true, force: true });
137
+ } catch {}
138
+ }
139
+ } catch {}
140
+ }
141
+
142
+ function runPdfInfo(pdfPath, workspaceDir) {
143
+ return new Promise((resolvePromise) => {
144
+ let stdout = '';
145
+ let proc;
146
+ try {
147
+ proc = spawn(resolveTool('pdfinfo.exe'), [pdfPath], {
148
+ cwd: workspaceDir,
149
+ env: miktexEnv(),
150
+ windowsHide: true,
151
+ });
152
+ } catch {
153
+ resolvePromise(null);
154
+ return;
155
+ }
156
+ proc.stdout.on('data', (d) => {
157
+ stdout += d.toString();
158
+ });
159
+ proc.on('error', () => resolvePromise(null));
160
+ proc.on('close', () => {
161
+ const pages = /^Pages:\s+(\d+)/m.exec(stdout);
162
+ const size = /^Page size:\s+([\d.]+)\s+x\s+([\d.]+)\s+pts/m.exec(stdout);
163
+ if (!pages) {
164
+ resolvePromise(null);
165
+ return;
166
+ }
167
+ resolvePromise({
168
+ pageCount: Number(pages[1]),
169
+ widthPt: size ? Number(size[1]) : 595.28,
170
+ heightPt: size ? Number(size[2]) : 841.89,
171
+ });
172
+ });
173
+ });
174
+ }
175
+
176
+ async function readPdfMeta(workspaceDir) {
177
+ const pdfPath = join(workspaceDir, 'main.pdf');
178
+ if (!existsSync(pdfPath)) return null;
179
+ const st = statSync(pdfPath);
180
+ const cacheKey = `${pdfPath}@${st.mtimeMs}`;
181
+ const cached = pdfMetaCache.get(cacheKey);
182
+ if (cached) return { ...cached, pdfMtime: st.mtimeMs };
183
+ const meta = await runPdfInfo(pdfPath, workspaceDir);
184
+ if (!meta) return null;
185
+ pdfMetaCache.set(cacheKey, meta);
186
+ for (const key of pdfMetaCache.keys()) {
187
+ if (key !== cacheKey && key.startsWith(`${pdfPath}@`)) pdfMetaCache.delete(key);
188
+ }
189
+ return { ...meta, pdfMtime: st.mtimeMs };
190
+ }
191
+
192
+ function renderPagePng(workspaceDir, page) {
193
+ return new Promise((resolvePromise) => {
194
+ const pdfPath = join(workspaceDir, 'main.pdf');
195
+ if (!existsSync(pdfPath)) {
196
+ resolvePromise(null);
197
+ return;
198
+ }
199
+
200
+ const mtime = statSync(pdfPath).mtimeMs;
201
+ const dirName = String(Math.round(mtime));
202
+ const dir = join(pageCacheRoot(workspaceDir), dirName);
203
+ const output = join(dir, `p${page}.png`);
204
+
205
+ if (existsSync(output)) {
206
+ resolvePromise(output);
207
+ return;
208
+ }
209
+
210
+ if (pageRenderJobs.has(output)) {
211
+ resolvePromise(pageRenderJobs.get(output));
212
+ return;
213
+ }
214
+
215
+ const job = new Promise((done) => {
216
+ try {
217
+ mkdirSync(dir, { recursive: true });
218
+ } catch {
219
+ done(null);
220
+ return;
221
+ }
222
+
223
+ let proc;
224
+ try {
225
+ proc = spawn(
226
+ resolveTool('pdftoppm.exe'),
227
+ ['-f', String(page), '-l', String(page), '-r', String(PAGE_DPI), '-png', '-singlefile', pdfPath, join(dir, `p${page}`)],
228
+ { cwd: workspaceDir, env: miktexEnv(), windowsHide: true }
229
+ );
230
+ } catch {
231
+ done(null);
232
+ return;
233
+ }
234
+
235
+ proc.on('error', () => done(null));
236
+ proc.on('close', (code) => {
237
+ prunePageCache(workspaceDir, dirName);
238
+ done(code === 0 && existsSync(output) ? output : null);
239
+ });
240
+ }).finally(() => {
241
+ pageRenderJobs.delete(output);
242
+ });
243
+
244
+ pageRenderJobs.set(output, job);
245
+ resolvePromise(job);
246
+ });
247
+ }
248
+
249
+
250
+ function runCompile(workspaceDir, engine = 'xelatex') {
251
+ return new Promise((resolvePromise) => {
252
+ const mainTexPath = join(workspaceDir, 'main.tex');
253
+ if (!existsSync(mainTexPath)) {
254
+ resolvePromise({
255
+ ok: false,
256
+ log: 'Error: main.tex tidak ditemukan di root workspace. Buat dokumen main.tex terlebih dahulu.',
257
+ pdfExists: false,
258
+ durationMs: 0,
259
+ });
260
+ return;
261
+ }
262
+
263
+ isCompiling = true;
264
+ const startTime = Date.now();
265
+ const bin = resolveBinary(engine);
266
+ const args = ['-interaction=nonstopmode', '-halt-on-error', 'main.tex'];
267
+
268
+ const env = {
269
+ ...process.env,
270
+ PATH: `${MIKTEX_BIN_DIR};${process.env.PATH || ''}`,
271
+ };
272
+
273
+ let stdout = '';
274
+ let stderr = '';
275
+
276
+ const proc = spawn(bin, args, {
277
+ cwd: workspaceDir,
278
+ env,
279
+ windowsHide: true,
280
+ });
281
+
282
+ proc.stdout.on('data', (d) => {
283
+ stdout += d.toString();
284
+ });
285
+
286
+ proc.stderr.on('data', (d) => {
287
+ stderr += d.toString();
288
+ });
289
+
290
+ proc.on('error', (err) => {
291
+ isCompiling = false;
292
+ const durationMs = Date.now() - startTime;
293
+ const combinedLog = `Gagal menjalankan binary ${bin}: ${err.message}\n\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`;
294
+ lastLog = combinedLog;
295
+ lastSuccess = false;
296
+ lastCompileTime = Date.now();
297
+ resolvePromise({
298
+ ok: false,
299
+ log: combinedLog,
300
+ pdfExists: existsSync(join(workspaceDir, 'main.pdf')),
301
+ durationMs,
302
+ });
303
+ });
304
+
305
+ proc.on('close', (code) => {
306
+ isCompiling = false;
307
+ const durationMs = Date.now() - startTime;
308
+ const pdfPath = join(workspaceDir, 'main.pdf');
309
+ const pdfExists = existsSync(pdfPath);
310
+ const combinedLog = `${stdout}\n${stderr}`.trim();
311
+ const ok = code === 0 && pdfExists;
312
+
313
+ lastLog = combinedLog;
314
+ lastSuccess = ok;
315
+ lastCompileTime = Date.now();
316
+
317
+ resolvePromise({
318
+ ok,
319
+ code,
320
+ log: combinedLog,
321
+ pdfExists,
322
+ durationMs,
323
+ pdfMtime: pdfExists ? statSync(pdfPath).mtimeMs : 0,
324
+ });
325
+ });
326
+ });
327
+ }
328
+
329
+ function readJsonBody(req) {
330
+ return new Promise((resolvePromise) => {
331
+ let body = '';
332
+ req.on('data', (chunk) => {
333
+ body += chunk;
334
+ if (body.length > 1024 * 1024) {
335
+ req.destroy();
336
+ }
337
+ });
338
+ req.on('end', () => {
339
+ try {
340
+ resolvePromise(body ? JSON.parse(body) : {});
341
+ } catch {
342
+ resolvePromise({});
343
+ }
344
+ });
345
+ });
346
+ }
347
+
348
+ function safeRegisterRoute(webServer, route) {
349
+ try {
350
+ const table = route.kind === 'exact' ? webServer.exact : webServer.prefixes;
351
+ if (table && typeof table.delete === 'function' && table.has(route.path)) {
352
+ table.delete(route.path);
353
+ }
354
+ } catch {}
355
+ return webServer.register(route);
356
+ }
357
+
358
+ // ===========================================================================
359
+ // Gateway remote: bind 0.0.0.0 (wildcard) lalu proxy ke web server lokal.
360
+ // Web server DSH sendiri menolak bind wildcard, jadi plugin ini menyediakan
361
+ // pintu masuk tanpa autentikasi untuk jaringan lokal.
362
+ // ===========================================================================
363
+ const REMOTE_GATEWAY_PORT = Number.parseInt(process.env.DSH_LATEX_REMOTE_PORT || '60856', 10);
364
+
365
+ const HOP_BY_HOP_HEADERS = new Set([
366
+ 'connection',
367
+ 'keep-alive',
368
+ 'proxy-authenticate',
369
+ 'proxy-authorization',
370
+ 'te',
371
+ 'trailer',
372
+ 'transfer-encoding',
373
+ 'upgrade',
374
+ 'host',
375
+ 'cookie',
376
+ ]);
377
+
378
+ function listLanAddresses() {
379
+ const addresses = [];
380
+ try {
381
+ const interfaces = networkInterfaces();
382
+ for (const entries of Object.values(interfaces)) {
383
+ for (const entry of entries || []) {
384
+ if (!entry || entry.internal) continue;
385
+ if (entry.family !== 'IPv4' && entry.family !== 4) continue;
386
+ addresses.push(entry.address);
387
+ }
388
+ }
389
+ } catch {}
390
+ return addresses;
391
+ }
392
+
393
+ async function resolveAuthCookie(ctx, localOrigin) {
394
+ try {
395
+ const connection = ctx.get('connection');
396
+ if (!connection || typeof connection.authenticatedUrl !== 'function') return null;
397
+ const url = connection.authenticatedUrl(localOrigin);
398
+ const response = await fetch(url, { redirect: 'manual' });
399
+ const raw =
400
+ typeof response.headers.getSetCookie === 'function'
401
+ ? response.headers.getSetCookie()
402
+ : [response.headers.get('set-cookie')].filter(Boolean);
403
+ for (const line of raw) {
404
+ const pair = String(line).split(';')[0];
405
+ if (pair && pair.includes('=')) return pair.trim();
406
+ }
407
+ } catch {}
408
+ return null;
409
+ }
410
+
411
+ function startRemoteGateway(ctx) {
412
+ const origin = ctx.webServer.getOrigin();
413
+ const target = new URL(origin);
414
+ const targetHost = target.hostname;
415
+ const targetPort = Number(target.port || (target.protocol === 'https:' ? 443 : 80));
416
+ const localAuthority = target.host;
417
+
418
+ let cookiePromise = null;
419
+ const authCookie = () => {
420
+ if (cookiePromise === null) cookiePromise = resolveAuthCookie(ctx, origin);
421
+ return cookiePromise;
422
+ };
423
+
424
+ const proxyHeaders = (headers, cookie) => {
425
+ const out = {};
426
+ for (const [name, value] of Object.entries(headers)) {
427
+ const key = name.toLowerCase();
428
+ if (HOP_BY_HOP_HEADERS.has(key)) continue;
429
+ out[key] = value;
430
+ }
431
+ out.host = localAuthority;
432
+ if (out.origin !== undefined) out.origin = origin;
433
+ if (out.referer !== undefined) out.referer = `${origin}/`;
434
+ if (cookie) out.cookie = cookie;
435
+ return out;
436
+ };
437
+
438
+ const server = createHttpServer((req, res) => {
439
+ authCookie()
440
+ .then((cookie) => {
441
+ const outgoing = httpRequest(
442
+ {
443
+ host: targetHost,
444
+ port: targetPort,
445
+ method: req.method,
446
+ path: req.url,
447
+ headers: proxyHeaders(req.headers, cookie),
448
+ },
449
+ (upstreamResponse) => {
450
+ res.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
451
+ upstreamResponse.pipe(res);
452
+ }
453
+ );
454
+ outgoing.on('error', () => {
455
+ try {
456
+ res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
457
+ res.end('latex-editor: gateway remote gagal menghubungi server lokal');
458
+ } catch {}
459
+ });
460
+ req.pipe(outgoing);
461
+ })
462
+ .catch(() => {
463
+ try {
464
+ res.writeHead(500);
465
+ res.end();
466
+ } catch {}
467
+ });
468
+ });
469
+
470
+ server.on('upgrade', (req, socket, head) => {
471
+ authCookie()
472
+ .then((cookie) => {
473
+ const headers = proxyHeaders(req.headers, cookie);
474
+ headers.connection = 'Upgrade';
475
+ headers.upgrade = req.headers.upgrade || 'websocket';
476
+
477
+ let raw = `${req.method} ${req.url} HTTP/1.1\r\n`;
478
+ for (const [name, value] of Object.entries(headers)) {
479
+ raw += `${name}: ${Array.isArray(value) ? value.join(', ') : value}\r\n`;
480
+ }
481
+ raw += '\r\n';
482
+
483
+ const upstream = netConnect(targetPort, targetHost, () => {
484
+ upstream.write(raw);
485
+ if (head && head.length > 0) upstream.write(head);
486
+ upstream.pipe(socket);
487
+ socket.pipe(upstream);
488
+ });
489
+ upstream.on('error', () => socket.destroy());
490
+ socket.on('error', () => upstream.destroy());
491
+ socket.on('close', () => upstream.destroy());
492
+ })
493
+ .catch(() => socket.destroy());
494
+ });
495
+
496
+ server.on('error', (error) => {
497
+ console.warn(`latex-editor: gateway remote tidak dapat bind port ${REMOTE_GATEWAY_PORT}: ${error.message}`);
498
+ });
499
+
500
+ server.listen(REMOTE_GATEWAY_PORT, '0.0.0.0', () => {
501
+ const urls = listLanAddresses().map((address) => `http://${address}:${REMOTE_GATEWAY_PORT}`);
502
+ console.log(`latex-editor: gateway remote aktif (tanpa auth) di 0.0.0.0:${REMOTE_GATEWAY_PORT}`);
503
+ for (const url of urls) console.log(`latex-editor: remote URL ${url}`);
504
+ });
505
+
506
+ return () => {
507
+ try {
508
+ server.close();
509
+ } catch {}
510
+ };
511
+ }
512
+
513
+ // ===========================================================================
514
+ // Perbaikan: versi plugin sebelumnya membungkus workspaceRegistry.create
515
+ // (mode satu proyek). Bungkus itu hidup di instance service dan tidak ikut
516
+ // hilang saat plugin di-reload, jadi dilepas di sini agar pembuatan workspace
517
+ // kembali memakai implementasi asli.
518
+ // ===========================================================================
519
+ function repairWorkspaceRegistry(ctx) {
520
+ try {
521
+ const registry = ctx.workspaceRegistry;
522
+ if (!registry) return;
523
+ if (registry.__latexSingleWorkspace === true) {
524
+ try {
525
+ delete registry.create;
526
+ } catch {}
527
+ try {
528
+ delete registry.__latexSingleWorkspace;
529
+ } catch {}
530
+ }
531
+ } catch {}
532
+ }
533
+
534
+ // ===========================================================================
535
+ // Pembuatan workspace langsung (tanpa dialog): folder dibuat otomatis di
536
+ // samping workspace default, lalu didaftarkan ke workspaceRegistry.
537
+ // ===========================================================================
538
+ async function createAutoWorkspace(ctx, options = {}) {
539
+ const registry = ctx.workspaceRegistry;
540
+ if (!registry || typeof registry.create !== 'function') {
541
+ throw new Error('Layanan workspace tidak tersedia.');
542
+ }
543
+ repairWorkspaceRegistry(ctx);
544
+
545
+ const list = typeof registry.list === 'function' ? registry.list() : [];
546
+ const base = list.find((item) => item && typeof item.path === 'string' && item.path)?.path || resolveWorkspace(ctx);
547
+ const parent = dirname(base);
548
+ const taken = new Set(list.filter((item) => item && item.path).map((item) => item.path.toLowerCase()));
549
+
550
+ let index = 1;
551
+ let title = '';
552
+ let target = '';
553
+ for (;;) {
554
+ title = `proyek-${index}`;
555
+ target = join(parent, title);
556
+
557
+ if (taken.has(target.toLowerCase())) {
558
+ index += 1;
559
+ continue;
560
+ }
561
+
562
+ if (!existsSync(target)) break;
563
+
564
+ // Folder sisa percobaan sebelumnya boleh dipakai ulang selama masih kosong.
565
+ let reusable = false;
566
+ try {
567
+ reusable = readdirSync(target).length === 0;
568
+ } catch {}
569
+ if (reusable) break;
570
+
571
+ index += 1;
572
+ if (index > 500) throw new Error('Tidak dapat menentukan nama folder proyek baru.');
573
+ }
574
+
575
+ if (options.dryRun === true) {
576
+ return { id: null, path: target, title, dryRun: true };
577
+ }
578
+
579
+ mkdirSync(target, { recursive: true });
580
+ const workspace = await registry.create(target, title);
581
+ return {
582
+ id: workspace?.id ?? null,
583
+ path: workspace?.path ?? target,
584
+ title: workspace?.title ?? title,
585
+ };
586
+ }
587
+
588
+ export function apply(ctx) {
589
+ repairWorkspaceRegistry(ctx);
590
+ // 1. INJEKSI SOUL / SYSTEM PROMPT (Wajib generate main.tex sebagai entry point)
591
+ // =========================================================================
592
+ if (ctx.systemPrompt && typeof ctx.systemPrompt.section === 'function') {
593
+ ctx.effect(() =>
594
+ ctx.systemPrompt.section({
595
+ name: 'latex-editor-core-mandate',
596
+ order: 5,
597
+ text: `
598
+ # PERAN & INSTRUKSI UTAMA: AI LATEX EDITOR (DEEPSEEK HARNESS)
599
+
600
+ Anda beroperasi sebagai asisten khusus LaTeX Editor interaktif dengan live compiler & PDF viewer.
601
+
602
+ ## ATURAN MUTLAK ENTRY POINT: main.tex
603
+ 1. Dokumen utama atau entry point proyek LaTeX HARUS SELALU bernama 'main.tex'.
604
+ 2. Setiap kali membuat dokumen baru, mengedit artikel, laporan, tugas, CV, paper ilmiah, atau presentasi Beamer, Anda WAJIB membuat atau memperbarui file 'main.tex' di root direktori workspace.
605
+ 3. 'main.tex' harus lengkap, valid, dan self-contained (memiliki \\documentclass, package-package yang diperlukan, \\begin{document}, dan \\end{document}).
606
+ 4. Jika proyek memerlukan pemisahan bab atau sub-berkas (misal: chapters/bab1.tex), pastikan berkas induk 'main.tex' memuat berkas-berkas tersebut menggunakan \\input{...} atau \\include{...}.
607
+ 5. Gunakan paket-paket standar yang kompatibel dengan XeLaTeX dan pdfLaTeX (misalnya: amsmath, amssymb, graphicx, hyperref, geometry, booktabs, xcolor). Hindari sintaksis usang atau paket non-standar yang memerlukan interaksi manual.
608
+ 6. Tampilan pratinjau PDF dan tombol 'Compile LaTeX' berada tepat di sebelah kanan layar chat ini. Setelah Anda selesai menulis atau memperbarui 'main.tex', beri tahu pengguna bahwa dokumen siap dikompilasi atau dapat dilihat langsung pada panel PDF sebelah kanan.
609
+ `.trim(),
610
+ })
611
+ );
612
+ }
613
+
614
+ // =========================================================================
615
+ // 2. REGISTRASI AGENT TOOL: latex_compile
616
+ // =========================================================================
617
+ if (ctx.tools && typeof ctx.tools.register === 'function') {
618
+ ctx.effect(() => {
619
+ try {
620
+ return ctx.tools.register({
621
+ name: 'latex_compile',
622
+ description: 'Mengompilasi berkas main.tex di workspace menjadi main.pdf menggunakan engine XeLaTeX atau pdfLaTeX.',
623
+ parameters: {
624
+ type: 'object',
625
+ properties: {
626
+ engine: {
627
+ type: 'string',
628
+ enum: ['xelatex', 'pdflatex'],
629
+ description: 'Engine LaTeX yang digunakan untuk kompilasi. Default: xelatex.',
630
+ },
631
+ },
632
+ },
633
+ output: {
634
+ schema: {
635
+ type: 'object',
636
+ properties: {
637
+ ok: { type: 'boolean' },
638
+ durationMs: { type: 'number' },
639
+ logSnippet: { type: 'string' },
640
+ },
641
+ required: ['ok'],
642
+ },
643
+ render(args, val) {
644
+ const v = val || {};
645
+ if (v.ok) {
646
+ return [{ type: 'text', text: `LaTeX compilation succeeded! main.pdf diperbarui dalam ${v.durationMs || 0}ms.` }];
647
+ }
648
+ return [{ type: 'text', text: `Kompilasi LaTeX gagal:\n${v.logSnippet || 'Tidak ada detail log'}` }];
649
+ },
650
+ },
651
+ async execute(args) {
652
+ const workspaceDir = resolveWorkspace(ctx);
653
+ const engine = args?.engine || 'xelatex';
654
+ const res = await runCompile(workspaceDir, engine);
655
+ const logSnippet = (res.log || '').split('\n').slice(-25).join('\n');
656
+ return {
657
+ ok: res.ok,
658
+ durationMs: res.durationMs,
659
+ logSnippet,
660
+ };
661
+ },
662
+ });
663
+ } catch {
664
+ return () => {};
665
+ }
666
+ });
667
+ }
668
+
669
+ // =========================================================================
670
+ // 3. HTTP ENDPOINTS PADA ctx.webServer
671
+ // =========================================================================
672
+ if (ctx.webServer && typeof ctx.webServer.register === 'function') {
673
+ // 3.1. POST /api/latex/compile
674
+ ctx.effect(() =>
675
+ safeRegisterRoute(ctx.webServer, {
676
+ kind: 'exact',
677
+ path: '/api/latex/compile',
678
+ handler: async (req, res) => {
679
+ res.setHeader('Access-Control-Allow-Origin', '*');
680
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
681
+ res.setHeader('Access-Control-Allow-Headers', '*');
682
+
683
+ if (req.method === 'OPTIONS') {
684
+ res.writeHead(204);
685
+ res.end();
686
+ return;
687
+ }
688
+
689
+ const body = await readJsonBody(req);
690
+ const engine = body.engine || 'xelatex';
691
+ const requestedWs = readRequestedWorkspace(req, body);
692
+ const selectedDir = pickWorkspace(ctx, requestedWs);
693
+
694
+ const result = await runCompile(selectedDir, engine);
695
+
696
+ res.writeHead(200, { 'Content-Type': 'application/json' });
697
+ res.end(JSON.stringify({ ...result, workspace: selectedDir }));
698
+ },
699
+ })
700
+ );
701
+
702
+ // 3.2. GET /api/latex/pdf
703
+ ctx.effect(() =>
704
+ safeRegisterRoute(ctx.webServer, {
705
+ kind: 'exact',
706
+ path: '/api/latex/pdf',
707
+ handler: async (req, res) => {
708
+ res.setHeader('Access-Control-Allow-Origin', '*');
709
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
710
+
711
+ if (req.method === 'OPTIONS') {
712
+ res.writeHead(204);
713
+ res.end();
714
+ return;
715
+ }
716
+
717
+ const workspaceDir = pickWorkspace(ctx, readRequestedWorkspace(req));
718
+ const pdfPath = join(workspaceDir, 'main.pdf');
719
+
720
+ if (!existsSync(pdfPath)) {
721
+ res.writeHead(404, { 'Content-Type': 'application/json' });
722
+ res.end(JSON.stringify({ error: 'main.pdf belum tersedia. Silakan kompilasi main.tex terlebih dahulu.' }));
723
+ return;
724
+ }
725
+
726
+ try {
727
+ const stat = statSync(pdfPath);
728
+ res.writeHead(200, {
729
+ 'Content-Type': 'application/pdf',
730
+ 'Content-Length': stat.size,
731
+ 'Content-Disposition': 'inline; filename="main.pdf"',
732
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
733
+ });
734
+ createReadStream(pdfPath).pipe(res);
735
+ } catch (err) {
736
+ res.writeHead(500, { 'Content-Type': 'application/json' });
737
+ res.end(JSON.stringify({ error: err.message }));
738
+ }
739
+ },
740
+ })
741
+ );
742
+
743
+ // 3.3. GET /api/latex/status
744
+ ctx.effect(() =>
745
+ safeRegisterRoute(ctx.webServer, {
746
+ kind: 'exact',
747
+ path: '/api/latex/status',
748
+ handler: async (req, res) => {
749
+ res.setHeader('Access-Control-Allow-Origin', '*');
750
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
751
+
752
+ if (req.method === 'OPTIONS') {
753
+ res.writeHead(204);
754
+ res.end();
755
+ return;
756
+ }
757
+
758
+ const workspaceDir = pickWorkspace(ctx, readRequestedWorkspace(req));
759
+ const texPath = join(workspaceDir, 'main.tex');
760
+ const pdfPath = join(workspaceDir, 'main.pdf');
761
+
762
+ const mainTexExists = existsSync(texPath);
763
+ const mainPdfExists = existsSync(pdfPath);
764
+
765
+ const texMtime = mainTexExists ? statSync(texPath).mtimeMs : 0;
766
+ const pdfMtime = mainPdfExists ? statSync(pdfPath).mtimeMs : 0;
767
+
768
+ let pageCount = 0;
769
+ let widthPt = 595.28;
770
+ let heightPt = 841.89;
771
+ if (mainPdfExists) {
772
+ const meta = await readPdfMeta(workspaceDir);
773
+ if (meta) {
774
+ pageCount = meta.pageCount;
775
+ widthPt = meta.widthPt;
776
+ heightPt = meta.heightPt;
777
+ }
778
+ }
779
+
780
+ res.writeHead(200, { 'Content-Type': 'application/json' });
781
+ res.end(
782
+ JSON.stringify({
783
+ ok: true,
784
+ workspace: workspaceDir,
785
+ mainTexExists,
786
+ mainPdfExists,
787
+ texMtime,
788
+ pdfMtime,
789
+ pageCount,
790
+ widthPt,
791
+ heightPt,
792
+ needsCompile: mainTexExists && texMtime > pdfMtime,
793
+ isCompiling,
794
+ lastLog,
795
+ lastSuccess,
796
+ lastCompileTime,
797
+ })
798
+ );
799
+ },
800
+ })
801
+ );
802
+
803
+ // 3.4. GET /api/latex/pages - Metadata halaman PDF untuk viewer internal
804
+ ctx.effect(() =>
805
+ safeRegisterRoute(ctx.webServer, {
806
+ kind: 'exact',
807
+ path: '/api/latex/pages',
808
+ handler: async (req, res) => {
809
+ res.setHeader('Access-Control-Allow-Origin', '*');
810
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
811
+
812
+ if (req.method === 'OPTIONS') {
813
+ res.writeHead(204);
814
+ res.end();
815
+ return;
816
+ }
817
+
818
+ const workspaceDir = pickWorkspace(ctx, readRequestedWorkspace(req));
819
+ const meta = await readPdfMeta(workspaceDir);
820
+
821
+ res.writeHead(200, {
822
+ 'Content-Type': 'application/json',
823
+ 'Cache-Control': 'no-store',
824
+ });
825
+ res.end(
826
+ JSON.stringify({
827
+ ok: Boolean(meta),
828
+ pageCount: meta ? meta.pageCount : 0,
829
+ widthPt: meta ? meta.widthPt : 595.28,
830
+ heightPt: meta ? meta.heightPt : 841.89,
831
+ pdfMtime: meta ? meta.pdfMtime : 0,
832
+ })
833
+ );
834
+ },
835
+ })
836
+ );
837
+
838
+ // 3.5. GET /api/latex/page?n=<nomor> - Raster PNG satu halaman
839
+ ctx.effect(() =>
840
+ safeRegisterRoute(ctx.webServer, {
841
+ kind: 'exact',
842
+ path: '/api/latex/page',
843
+ handler: async (req, res) => {
844
+ res.setHeader('Access-Control-Allow-Origin', '*');
845
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
846
+
847
+ if (req.method === 'OPTIONS') {
848
+ res.writeHead(204);
849
+ res.end();
850
+ return;
851
+ }
852
+
853
+ const workspaceDir = pickWorkspace(ctx, readRequestedWorkspace(req));
854
+ let page = 1;
855
+ try {
856
+ const url = new URL(req.url || '/', 'http://localhost');
857
+ page = Number.parseInt(url.searchParams.get('n') || '1', 10);
858
+ } catch {}
859
+ if (!Number.isFinite(page) || page < 1 || page > 5000) {
860
+ res.writeHead(400, { 'Content-Type': 'application/json' });
861
+ res.end(JSON.stringify({ error: 'Nomor halaman tidak valid.' }));
862
+ return;
863
+ }
864
+
865
+ const pngPath = await renderPagePng(workspaceDir, page);
866
+ if (!pngPath || !existsSync(pngPath)) {
867
+ res.writeHead(404, { 'Content-Type': 'application/json' });
868
+ res.end(JSON.stringify({ error: `Halaman ${page} belum tersedia.` }));
869
+ return;
870
+ }
871
+
872
+ try {
873
+ const stat = statSync(pngPath);
874
+ res.writeHead(200, {
875
+ 'Content-Type': 'image/png',
876
+ 'Content-Length': stat.size,
877
+ 'Cache-Control': 'private, max-age=31536000, immutable',
878
+ });
879
+ createReadStream(pngPath).pipe(res);
880
+ } catch (err) {
881
+ res.writeHead(500, { 'Content-Type': 'application/json' });
882
+ res.end(JSON.stringify({ error: err.message }));
883
+ }
884
+ },
885
+ })
886
+ );
887
+
888
+ // 3.6. POST /api/latex/init
889
+ ctx.effect(() =>
890
+ safeRegisterRoute(ctx.webServer, {
891
+ kind: 'exact',
892
+ path: '/api/latex/init',
893
+ handler: async (req, res) => {
894
+ res.setHeader('Access-Control-Allow-Origin', '*');
895
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
896
+
897
+ if (req.method === 'OPTIONS') {
898
+ res.writeHead(204);
899
+ res.end();
900
+ return;
901
+ }
902
+
903
+ const workspaceDir = pickWorkspace(ctx, readRequestedWorkspace(req));
904
+ const texPath = join(workspaceDir, 'main.tex');
905
+
906
+ if (!existsSync(texPath)) {
907
+ const starterTemplate = `\\documentclass[11pt,a4paper]{article}
908
+ \\usepackage[utf8]{inputenc}
909
+ \\usepackage{amsmath,amssymb}
910
+ \\usepackage{graphicx}
911
+ \\usepackage{hyperref}
912
+ \\usepackage{geometry}
913
+ \\geometry{a4paper, margin=1in}
914
+
915
+ \\title{\\textbf{Dokumen LaTeX AI}}
916
+ \\author{Dibuat dengan DeepSeek Harness}
917
+ \\date{\\today}
918
+
919
+ \\begin{document}
920
+
921
+ \\maketitle
922
+
923
+ \\begin{abstract}
924
+ Dokumen ini dibuat otomatis oleh DeepSeek Harness LaTeX Editor AI. Anda dapat langsung mengedit berkas \\texttt{main.tex} ini atau meminta AI di kolom chat untuk menulis artikel, rumus matematika, atau laporan lengkap.
925
+ \\end{abstract}
926
+
927
+ \\section{Pendahuluan}
928
+ Selamat datang di \\textbf{LaTeX AI Studio}! Dokumen ini adalah berkas \\texttt{main.tex} yang berfungsi sebagai entry point utama.
929
+
930
+ \\section{Contoh Persamaan Matematika}
931
+ Berikut adalah contoh persamaan kuadrat dan integral kalkulus:
932
+ \\begin{equation}
933
+ E = mc^2
934
+ \\end{equation}
935
+
936
+ \\begin{equation}
937
+ \\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}
938
+ \\end{equation}
939
+
940
+ \\section{Kesimpulan}
941
+ Kompilasi berkas ini dengan menekan tombol \\textbf{Compile} di panel pratinjau sebelah kanan.
942
+
943
+ \\end{document}
944
+ `;
945
+ writeFileSync(texPath, starterTemplate, 'utf8');
946
+ }
947
+
948
+ res.writeHead(200, { 'Content-Type': 'application/json' });
949
+ res.end(JSON.stringify({ ok: true, created: true }));
950
+ },
951
+ })
952
+ );
953
+
954
+ // 3.7. POST /api/latex/workspace/new - Buat workspace + folder langsung
955
+ ctx.effect(() =>
956
+ safeRegisterRoute(ctx.webServer, {
957
+ kind: 'exact',
958
+ path: '/api/latex/workspace/new',
959
+ handler: async (req, res) => {
960
+ res.setHeader('Access-Control-Allow-Origin', '*');
961
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
962
+ res.setHeader('Access-Control-Allow-Headers', '*');
963
+
964
+ if (req.method === 'OPTIONS') {
965
+ res.writeHead(204);
966
+ res.end();
967
+ return;
968
+ }
969
+
970
+ try {
971
+ let dryRun = false;
972
+ try {
973
+ const url = new URL(req.url || '/', 'http://localhost');
974
+ dryRun = url.searchParams.get('dry') === '1';
975
+ } catch {}
976
+ const workspace = await createAutoWorkspace(ctx, { dryRun });
977
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
978
+ res.end(JSON.stringify({ ok: true, ...workspace }));
979
+ } catch (error) {
980
+ res.writeHead(500, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
981
+ res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
982
+ }
983
+ },
984
+ })
985
+ );
986
+ }
987
+
988
+ // =========================================================================
989
+ // 5. GATEWAY REMOTE WILDCARD (0.0.0.0) TANPA AUTH
990
+ // =========================================================================
991
+ if (ctx.webServer && typeof ctx.webServer.getOrigin === 'function') {
992
+ ctx.effect(() => startRemoteGateway(ctx));
993
+ }
994
+ }