@orxataguy/tyr 1.0.30 → 1.0.32

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.
@@ -0,0 +1,880 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import http, { IncomingMessage, ServerResponse } from 'node:http';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import fsSync from 'node:fs';
6
+ import fsp from 'node:fs/promises';
7
+ import crypto from 'node:crypto';
8
+
9
+ import { Logger } from '../core/Logger.js';
10
+ import { TyrError } from '../core/TyrError.js';
11
+ import { FileSystemManager } from './FileSystemManager.js';
12
+
13
+ export type ChatRole = 'user' | 'assistant' | 'error';
14
+
15
+ export interface ChatAttachment {
16
+ id: string;
17
+ filename: string;
18
+ path: string;
19
+ mimeType: string;
20
+ size: number;
21
+ }
22
+
23
+ export interface ChatMessage {
24
+ id: string;
25
+ role: ChatRole;
26
+ text: string;
27
+ attachments: ChatAttachment[];
28
+ createdAt: number;
29
+ }
30
+
31
+ export interface ChatOpenOptions {
32
+ /** Preferred port. If busy, the next free port is used instead (default: 4646). */
33
+ port?: number;
34
+ /** Browser tab / header title (default: "Tyr Chat — <dirname>"). */
35
+ title?: string;
36
+ /** Initial fraction of the width given to the chat pane, 0.2–0.8 (default: 0.4). The user
37
+ * can still resize it live by dragging the divider between the two panes. */
38
+ splitRatio?: number;
39
+ }
40
+
41
+ export interface ChatSession {
42
+ id: string;
43
+ dir: string;
44
+ /** Temp folder where attached images are written. Removed on stop() / process exit. */
45
+ tempDir: string;
46
+ port: number;
47
+ url: string;
48
+ stop: () => Promise<void>;
49
+ }
50
+
51
+ export interface ChatMessageContext {
52
+ message: ChatMessage;
53
+ /** Full conversation so far, including the message that triggered this call. */
54
+ history: ChatMessage[];
55
+ dir: string;
56
+ }
57
+
58
+ /** Function that produces the assistant's reply text for a user message. Registered via
59
+ * chat.onMessage() — this is where a consumer wires in an AI vendor, a static responder, etc. */
60
+ export type ChatMessageHandler = (ctx: ChatMessageContext) => Promise<string> | string;
61
+
62
+ export type ChatEventName =
63
+ | 'chat:open'
64
+ | 'chat:close'
65
+ | 'message:send'
66
+ | 'message:response'
67
+ | 'message:error'
68
+ | 'file:select';
69
+
70
+ interface InternalSession {
71
+ id: string;
72
+ dir: string;
73
+ tempDir: string;
74
+ port: number;
75
+ server: http.Server;
76
+ history: ChatMessage[];
77
+ attachments: Map<string, ChatAttachment>;
78
+ splitRatio: number;
79
+ title: string;
80
+ }
81
+
82
+ const DEFAULT_PORT = 4646;
83
+ const MAX_PORT_ATTEMPTS = 20;
84
+ const MAX_PREVIEW_BYTES = 1_000_000;
85
+ const MAX_UPLOAD_BYTES = 30_000_000;
86
+ const IGNORED_ENTRIES = new Set(['node_modules', '.git', '.DS_Store']);
87
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']);
88
+
89
+ const MIME_TYPES: Record<string, string> = {
90
+ '.png': 'image/png',
91
+ '.jpg': 'image/jpeg',
92
+ '.jpeg': 'image/jpeg',
93
+ '.gif': 'image/gif',
94
+ '.webp': 'image/webp',
95
+ '.svg': 'image/svg+xml',
96
+ '.bmp': 'image/bmp',
97
+ '.json': 'application/json',
98
+ '.html': 'text/html',
99
+ '.css': 'text/css',
100
+ '.js': 'text/javascript',
101
+ '.ts': 'text/plain',
102
+ '.txt': 'text/plain',
103
+ '.md': 'text/markdown',
104
+ };
105
+
106
+ function mimeFromExt(ext: string): string {
107
+ return MIME_TYPES[ext.toLowerCase()] ?? 'application/octet-stream';
108
+ }
109
+
110
+ /**
111
+ * @class ChatManager
112
+ * @description Runs a self-contained local web app — chat on one side, a file browser for a
113
+ * given directory on the other — and gives commands a hook-based API to drive it. The manager
114
+ * itself never talks to an AI vendor: the caller registers `onMessage()` to decide how a reply
115
+ * is produced (e.g. by calling AIVendorManager), and can subscribe to lifecycle events with
116
+ * `on()` for side effects (logging, persistence, analytics...) around sends, responses, errors
117
+ * and file selection.
118
+ *
119
+ * Attached images are written to a per-session temp directory (via os.tmpdir()) that is removed
120
+ * when the session is stopped or the process exits (SIGINT/SIGTERM/exit are all hooked).
121
+ */
122
+ export class ChatManager {
123
+ private fsManager: FileSystemManager;
124
+ private logger: Logger;
125
+ private emitter = new EventEmitter();
126
+ private messageHandler: ChatMessageHandler | null = null;
127
+ private sessions = new Map<string, InternalSession>();
128
+ private exitHooked = false;
129
+
130
+ constructor(fsManager: FileSystemManager, logger: Logger) {
131
+ this.fsManager = fsManager;
132
+ this.logger = logger;
133
+ }
134
+
135
+ /**
136
+ * @method onMessage
137
+ * @description Registers the function responsible for producing the assistant's reply to a
138
+ * user message. There is a single active handler per manager instance — call again to
139
+ * replace it. If no handler is registered, incoming messages fail with a TyrError.
140
+ * @param {ChatMessageHandler} handler - Receives { message, history, dir }, returns reply text.
141
+ * @example
142
+ * chat.onMessage(async ({ message, history, dir }) => {
143
+ * const result = await aiVendor.complete([
144
+ * { role: 'system', content: `You are assisting inside ${dir}.` },
145
+ * ...history.map(m => ({ role: m.role === 'user' ? 'user' : 'assistant', content: m.text })),
146
+ * ]);
147
+ * return result.content;
148
+ * });
149
+ */
150
+ public onMessage(handler: ChatMessageHandler): void {
151
+ this.messageHandler = handler;
152
+ }
153
+
154
+ /**
155
+ * @method on
156
+ * @description Subscribes to a chat lifecycle event. Multiple listeners can be registered
157
+ * per event; each is awaited in turn, and a throwing listener is logged and skipped rather
158
+ * than breaking the request.
159
+ * @param {ChatEventName} event - One of: chat:open, chat:close, message:send, message:response, message:error, file:select.
160
+ * @param {Function} listener - Called with an event-specific payload object.
161
+ * @example
162
+ * chat.on('message:send', ({ message }) => logger.info(`User sent: ${message.text}`));
163
+ * chat.on('message:response', ({ message }) => logger.info(`Assistant replied: ${message.text}`));
164
+ */
165
+ public on(event: ChatEventName, listener: (...args: any[]) => void): void {
166
+ this.emitter.on(event, listener);
167
+ }
168
+
169
+ /**
170
+ * @method off
171
+ * @description Removes a previously registered event listener.
172
+ * @param {ChatEventName} event - The event name passed to on().
173
+ * @param {Function} listener - The exact listener function reference to remove.
174
+ * @example
175
+ * chat.off('file:select', myListener);
176
+ */
177
+ public off(event: ChatEventName, listener: (...args: any[]) => void): void {
178
+ this.emitter.off(event, listener);
179
+ }
180
+
181
+ private async emitSafe(event: ChatEventName, payload: any): Promise<void> {
182
+ for (const listener of this.emitter.listeners(event)) {
183
+ try {
184
+ await listener(payload);
185
+ } catch (e) {
186
+ this.logger.warn(`Chat hook '${event}' threw: ${(e as Error).message}`);
187
+ }
188
+ }
189
+ }
190
+
191
+ private registerExitCleanup(): void {
192
+ if (this.exitHooked) return;
193
+ this.exitHooked = true;
194
+
195
+ const cleanup = () => {
196
+ for (const session of this.sessions.values()) {
197
+ try {
198
+ fsSync.rmSync(session.tempDir, { recursive: true, force: true });
199
+ } catch {
200
+ // best-effort cleanup on shutdown
201
+ }
202
+ }
203
+ };
204
+
205
+ process.once('exit', cleanup);
206
+ process.once('SIGINT', () => { cleanup(); process.exit(0); });
207
+ process.once('SIGTERM', () => { cleanup(); process.exit(0); });
208
+ }
209
+
210
+ /**
211
+ * @method open
212
+ * @description Starts the chat + file browser server for a directory. Returns immediately
213
+ * once the server is listening; the returned session exposes the URL to open and a stop()
214
+ * to shut the server down and delete the temp attachments folder.
215
+ * @param {string} dir - Directory to browse (also used as the chat's working context).
216
+ * @param {ChatOpenOptions} options - port, title, splitRatio (see type for defaults).
217
+ * @returns {Promise<ChatSession>} The running session.
218
+ * @example
219
+ * const session = await chat.open('./my-project', { splitRatio: 0.35 });
220
+ * logger.success(`Chat ready at ${session.url}`);
221
+ */
222
+ public async open(dir: string, options: ChatOpenOptions = {}): Promise<ChatSession> {
223
+ const resolvedDir = path.resolve(this.fsManager.expandPath(dir));
224
+
225
+ if (!this.fsManager.exists(resolvedDir)) {
226
+ throw new TyrError(`Directory not found: ${resolvedDir}`, null, 'Pass an existing directory to chat.open().');
227
+ }
228
+ if (!fsSync.statSync(resolvedDir).isDirectory()) {
229
+ throw new TyrError(`Not a directory: ${resolvedDir}`);
230
+ }
231
+
232
+ const id = crypto.randomUUID();
233
+ const tempDir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'tyr-chat-'));
234
+ const splitRatio = Math.min(0.8, Math.max(0.2, options.splitRatio ?? 0.4));
235
+ const title = options.title ?? `Tyr Chat — ${path.basename(resolvedDir)}`;
236
+
237
+ const session: InternalSession = {
238
+ id,
239
+ dir: resolvedDir,
240
+ tempDir,
241
+ port: 0,
242
+ server: null as unknown as http.Server,
243
+ history: [],
244
+ attachments: new Map(),
245
+ splitRatio,
246
+ title,
247
+ };
248
+
249
+ const server = http.createServer((req, res) => {
250
+ this.handleRequest(session, req, res);
251
+ });
252
+ session.server = server;
253
+
254
+ try {
255
+ session.port = await this.listen(server, options.port ?? DEFAULT_PORT);
256
+ } catch (e) {
257
+ fsSync.rmSync(tempDir, { recursive: true, force: true });
258
+ throw new TyrError('Could not start chat server', e, 'Pass a free port via options.port.');
259
+ }
260
+
261
+ this.sessions.set(id, session);
262
+ this.registerExitCleanup();
263
+
264
+ const chatSession: ChatSession = {
265
+ id,
266
+ dir: resolvedDir,
267
+ tempDir,
268
+ port: session.port,
269
+ url: `http://localhost:${session.port}`,
270
+ stop: () => this.stop(id),
271
+ };
272
+
273
+ await this.emitSafe('chat:open', { session: chatSession });
274
+ return chatSession;
275
+ }
276
+
277
+ private listen(server: http.Server, preferredPort: number): Promise<number> {
278
+ return new Promise((resolve, reject) => {
279
+ let attempt = 0;
280
+
281
+ const tryPort = (port: number) => {
282
+ const onError = (err: NodeJS.ErrnoException) => {
283
+ if (err.code === 'EADDRINUSE' && attempt < MAX_PORT_ATTEMPTS) {
284
+ attempt++;
285
+ tryPort(port + 1);
286
+ } else {
287
+ reject(err);
288
+ }
289
+ };
290
+ server.once('error', onError);
291
+ server.listen(port, () => {
292
+ server.removeListener('error', onError);
293
+ const address = server.address();
294
+ resolve(typeof address === 'object' && address ? address.port : port);
295
+ });
296
+ };
297
+
298
+ tryPort(preferredPort);
299
+ });
300
+ }
301
+
302
+ /**
303
+ * @method stop
304
+ * @description Stops a running chat session: closes the HTTP server and deletes its temp
305
+ * attachments folder. Safe to call more than once.
306
+ * @param {string} sessionId - The id of the session returned by open().
307
+ * @example
308
+ * await session.stop();
309
+ */
310
+ public async stop(sessionId: string): Promise<void> {
311
+ const session = this.sessions.get(sessionId);
312
+ if (!session) return;
313
+
314
+ await this.emitSafe('chat:close', { sessionId });
315
+ await new Promise<void>((resolve) => session.server.close(() => resolve()));
316
+
317
+ try {
318
+ fsSync.rmSync(session.tempDir, { recursive: true, force: true });
319
+ } catch {
320
+ // best-effort cleanup
321
+ }
322
+
323
+ this.sessions.delete(sessionId);
324
+ }
325
+
326
+ // --- HTTP plumbing --------------------------------------------------------------------------
327
+
328
+ private sendJson(res: ServerResponse, status: number, payload: any): void {
329
+ const body = JSON.stringify(payload);
330
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
331
+ res.end(body);
332
+ }
333
+
334
+ private readJsonBody(req: IncomingMessage): Promise<any> {
335
+ return new Promise((resolve, reject) => {
336
+ let raw = '';
337
+ req.on('data', (chunk) => {
338
+ raw += chunk;
339
+ if (raw.length > MAX_UPLOAD_BYTES) req.destroy(new Error('Payload too large'));
340
+ });
341
+ req.on('end', () => {
342
+ if (!raw) { resolve({}); return; }
343
+ try {
344
+ resolve(JSON.parse(raw));
345
+ } catch (e) {
346
+ reject(new TyrError('Invalid JSON body', e));
347
+ }
348
+ });
349
+ req.on('error', reject);
350
+ });
351
+ }
352
+
353
+ private resolveSafe(session: InternalSession, relPath: string): string {
354
+ const cleaned = (relPath || '').replace(/^\/+/, '');
355
+ const resolved = path.resolve(session.dir, cleaned);
356
+ if (resolved !== session.dir && !resolved.startsWith(session.dir + path.sep)) {
357
+ throw new TyrError('Path escapes the chat directory', null, 'Do not use ".." in file paths.');
358
+ }
359
+ return resolved;
360
+ }
361
+
362
+ private async handleRequest(session: InternalSession, req: IncomingMessage, res: ServerResponse): Promise<void> {
363
+ try {
364
+ const url = new URL(req.url ?? '/', 'http://localhost');
365
+ const pathname = url.pathname;
366
+
367
+ if (pathname === '/' && req.method === 'GET') {
368
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
369
+ res.end(this.renderPage(session));
370
+ return;
371
+ }
372
+
373
+ if (pathname === '/api/history' && req.method === 'GET') {
374
+ this.sendJson(res, 200, {
375
+ history: session.history,
376
+ dir: session.dir,
377
+ splitRatio: session.splitRatio,
378
+ title: session.title,
379
+ });
380
+ return;
381
+ }
382
+
383
+ if (pathname === '/api/tree' && req.method === 'GET') {
384
+ const rel = url.searchParams.get('path') ?? '';
385
+ const entries = await this.listChildren(session, rel);
386
+ this.sendJson(res, 200, { entries });
387
+ return;
388
+ }
389
+
390
+ if (pathname === '/api/file' && req.method === 'GET') {
391
+ const rel = url.searchParams.get('path') ?? '';
392
+ const preview = await this.readFilePreview(session, rel);
393
+ await this.emitSafe('file:select', { path: rel, dir: session.dir });
394
+ this.sendJson(res, 200, preview);
395
+ return;
396
+ }
397
+
398
+ if (pathname === '/api/raw' && req.method === 'GET') {
399
+ const rel = url.searchParams.get('path') ?? '';
400
+ await this.serveRaw(session, rel, res);
401
+ return;
402
+ }
403
+
404
+ if (pathname === '/api/attachment' && req.method === 'GET') {
405
+ const id = url.searchParams.get('id') ?? '';
406
+ await this.serveAttachment(session, id, res);
407
+ return;
408
+ }
409
+
410
+ if (pathname === '/api/upload' && req.method === 'POST') {
411
+ const body = await this.readJsonBody(req);
412
+ const attachment = await this.saveAttachment(session, body);
413
+ this.sendJson(res, 200, { attachment });
414
+ return;
415
+ }
416
+
417
+ if (pathname === '/api/message' && req.method === 'POST') {
418
+ const body = await this.readJsonBody(req);
419
+ const message = await this.handleMessage(session, body);
420
+ this.sendJson(res, 200, { message });
421
+ return;
422
+ }
423
+
424
+ this.sendJson(res, 404, { error: 'Not found' });
425
+ } catch (e: any) {
426
+ const code = e?.originalError?.code ?? e?.code;
427
+ const status = code === 'ENOENT' ? 404 : e instanceof TyrError ? 400 : 500;
428
+ this.sendJson(res, status, { error: e?.message ?? 'Internal error' });
429
+ }
430
+ }
431
+
432
+ // --- File browser -----------------------------------------------------------------------
433
+
434
+ private async listChildren(session: InternalSession, relPath: string): Promise<Array<{ name: string; path: string; isDir: boolean }>> {
435
+ const absDir = this.resolveSafe(session, relPath);
436
+ const dirents = await fsp.readdir(absDir, { withFileTypes: true });
437
+ const base = relPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
438
+
439
+ return dirents
440
+ .filter((d) => !IGNORED_ENTRIES.has(d.name))
441
+ .map((d) => ({
442
+ name: d.name,
443
+ path: base ? `${base}/${d.name}` : d.name,
444
+ isDir: d.isDirectory(),
445
+ }))
446
+ .sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name));
447
+ }
448
+
449
+ private async readFilePreview(session: InternalSession, relPath: string): Promise<{
450
+ path: string; binary: boolean; truncated: boolean; content: string | null; size: number; isImage: boolean;
451
+ }> {
452
+ const abs = this.resolveSafe(session, relPath);
453
+ const stat = await fsp.stat(abs);
454
+ if (stat.isDirectory()) throw new TyrError('Cannot preview a directory');
455
+
456
+ const ext = path.extname(abs).toLowerCase();
457
+ if (IMAGE_EXTENSIONS.has(ext)) {
458
+ return { path: relPath, binary: true, truncated: false, content: null, size: stat.size, isImage: true };
459
+ }
460
+
461
+ const readLength = Math.min(stat.size, MAX_PREVIEW_BYTES);
462
+ const buffer = Buffer.alloc(readLength);
463
+ const fd = await fsp.open(abs, 'r');
464
+ try {
465
+ await fd.read(buffer, 0, readLength, 0);
466
+ } finally {
467
+ await fd.close();
468
+ }
469
+
470
+ const binary = buffer.subarray(0, Math.min(8000, buffer.length)).includes(0);
471
+ return {
472
+ path: relPath,
473
+ binary,
474
+ truncated: stat.size > MAX_PREVIEW_BYTES,
475
+ content: binary ? null : buffer.toString('utf-8'),
476
+ size: stat.size,
477
+ isImage: false,
478
+ };
479
+ }
480
+
481
+ private async serveRaw(session: InternalSession, relPath: string, res: ServerResponse): Promise<void> {
482
+ const abs = this.resolveSafe(session, relPath);
483
+ const stat = await fsp.stat(abs);
484
+ if (!stat.isFile()) {
485
+ this.sendJson(res, 404, { error: 'Not found' });
486
+ return;
487
+ }
488
+ res.writeHead(200, { 'Content-Type': mimeFromExt(path.extname(abs)), 'Content-Length': stat.size });
489
+ fsSync.createReadStream(abs).pipe(res);
490
+ }
491
+
492
+ private async serveAttachment(session: InternalSession, id: string, res: ServerResponse): Promise<void> {
493
+ const attachment = session.attachments.get(id);
494
+ if (!attachment) {
495
+ this.sendJson(res, 404, { error: 'Attachment not found' });
496
+ return;
497
+ }
498
+ const stat = await fsp.stat(attachment.path);
499
+ res.writeHead(200, { 'Content-Type': attachment.mimeType, 'Content-Length': stat.size });
500
+ fsSync.createReadStream(attachment.path).pipe(res);
501
+ }
502
+
503
+ private async saveAttachment(session: InternalSession, body: any): Promise<ChatAttachment> {
504
+ const filename = body?.filename;
505
+ const dataBase64 = body?.dataBase64;
506
+ if (!filename || !dataBase64) {
507
+ throw new TyrError('Missing filename or file data', null, 'Send { filename, mimeType, dataBase64 } to /api/upload.');
508
+ }
509
+
510
+ const id = crypto.randomUUID();
511
+ const safeName = String(filename).replace(/[^\w.\-]/g, '_');
512
+ const destPath = path.join(session.tempDir, `${id}-${safeName}`);
513
+ const buffer = Buffer.from(String(dataBase64), 'base64');
514
+
515
+ await fsp.writeFile(destPath, buffer);
516
+
517
+ const attachment: ChatAttachment = {
518
+ id,
519
+ filename: safeName,
520
+ path: destPath,
521
+ mimeType: body?.mimeType || 'application/octet-stream',
522
+ size: buffer.length,
523
+ };
524
+ session.attachments.set(id, attachment);
525
+ return attachment;
526
+ }
527
+
528
+ // --- Messaging --------------------------------------------------------------------------
529
+
530
+ private defaultHandler: ChatMessageHandler = () => {
531
+ throw new TyrError('No message handler registered', null, 'Call chat.onMessage(handler) before opening the chat.');
532
+ };
533
+
534
+ private async handleMessage(session: InternalSession, body: any): Promise<ChatMessage> {
535
+ const text = typeof body?.text === 'string' ? body.text : '';
536
+ const attachmentIds: string[] = Array.isArray(body?.attachmentIds) ? body.attachmentIds : [];
537
+ const attachments = attachmentIds
538
+ .map((id) => session.attachments.get(id))
539
+ .filter((a): a is ChatAttachment => !!a);
540
+
541
+ if (!text.trim() && attachments.length === 0) {
542
+ throw new TyrError('Cannot send an empty message');
543
+ }
544
+
545
+ const userMessage: ChatMessage = {
546
+ id: crypto.randomUUID(),
547
+ role: 'user',
548
+ text,
549
+ attachments,
550
+ createdAt: Date.now(),
551
+ };
552
+ session.history.push(userMessage);
553
+
554
+ await this.emitSafe('message:send', { message: userMessage, history: session.history, dir: session.dir });
555
+
556
+ try {
557
+ const handler = this.messageHandler ?? this.defaultHandler;
558
+ const replyText = await handler({ message: userMessage, history: session.history, dir: session.dir });
559
+
560
+ const assistantMessage: ChatMessage = {
561
+ id: crypto.randomUUID(),
562
+ role: 'assistant',
563
+ text: replyText,
564
+ attachments: [],
565
+ createdAt: Date.now(),
566
+ };
567
+ session.history.push(assistantMessage);
568
+
569
+ await this.emitSafe('message:response', { message: assistantMessage, history: session.history, dir: session.dir });
570
+ return assistantMessage;
571
+ } catch (e) {
572
+ await this.emitSafe('message:error', { error: e, message: userMessage, dir: session.dir });
573
+ throw e instanceof TyrError ? e : new TyrError('Chat message handler failed', e);
574
+ }
575
+ }
576
+
577
+ // --- UI -----------------------------------------------------------------------------------
578
+
579
+ private renderPage(session: InternalSession): string {
580
+ const bootstrap = JSON.stringify({
581
+ dir: session.dir,
582
+ splitRatio: session.splitRatio,
583
+ title: session.title,
584
+ }).replace(/</g, '\\u003c');
585
+
586
+ return `<!DOCTYPE html>
587
+ <html>
588
+ <head>
589
+ <meta charset="utf-8">
590
+ <meta name="viewport" content="width=device-width, initial-scale=1">
591
+ <title>${session.title}</title>
592
+ <style>
593
+ * { box-sizing: border-box; }
594
+ html, body { height: 100%; margin: 0; font-family: 'Segoe UI', sans-serif; background: #1b1b1b; color: #eee; }
595
+ #app { display: grid; grid-template-columns: var(--chat-w, 40%) 6px 1fr; height: 100vh; }
596
+ #chat-pane, #files-pane { display: flex; flex-direction: column; min-width: 220px; overflow: hidden; }
597
+ #chat-pane { border-right: 1px solid #333; }
598
+ #divider { cursor: col-resize; background: #262626; }
599
+ #divider:hover { background: #4db8ff; }
600
+ header { padding: 12px 16px; border-bottom: 1px solid #333; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
601
+ #messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
602
+ .msg { max-width: 85%; padding: 10px 14px; border-radius: 10px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; font-size: 14px; }
603
+ .msg.user { align-self: flex-end; background: #2563eb22; border: 1px solid #2563eb55; }
604
+ .msg.assistant { align-self: flex-start; background: #2d2d2d; border: 1px solid #3a3a3a; }
605
+ .msg.error { align-self: flex-start; background: #4d1f1f; border: 1px solid #7a2b2b; color: #ffb4b4; }
606
+ .attachments { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
607
+ .attachments img { width: 72px; height: 72px; object-fit: cover; border-radius: 6px; border: 1px solid #444; }
608
+ #compose { border-top: 1px solid #333; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
609
+ #pending-attachments { display: flex; gap: 6px; flex-wrap: wrap; }
610
+ .chip { position: relative; }
611
+ .chip img { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; border: 1px solid #444; }
612
+ .chip button { position: absolute; top: -6px; right: -6px; background: #c0392b; color: #fff; border: none; border-radius: 50%; width: 18px; height: 18px; cursor: pointer; font-size: 11px; line-height: 1; }
613
+ #compose-row { display: flex; gap: 8px; align-items: flex-end; }
614
+ textarea#msg-input { flex: 1; resize: none; background: #151515; color: #eee; border: 1px solid #333; border-radius: 8px; padding: 10px; font-family: inherit; font-size: 14px; max-height: 140px; min-height: 42px; }
615
+ button { cursor: pointer; border-radius: 8px; border: none; font-weight: 600; padding: 10px 14px; }
616
+ #send-btn { background: #4db8ff; color: #00121f; }
617
+ #send-btn:disabled { opacity: 0.5; cursor: default; }
618
+ #attach-btn { background: #333; color: #eee; }
619
+ #files-header span:last-child { color: #888; font-weight: 400; font-size: 12px; }
620
+ #tree { flex: 1; overflow-y: auto; padding: 8px; }
621
+ .tree-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; }
622
+ .tree-row:hover { background: #262626; }
623
+ .tree-children { margin-left: 16px; display: none; }
624
+ .tree-children.open { display: block; }
625
+ #preview { border-top: 1px solid #333; max-height: 45%; overflow: auto; padding: 12px; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 12.5px; }
626
+ #preview img { max-width: 100%; border-radius: 6px; }
627
+ #preview pre { white-space: pre-wrap; word-break: break-word; margin: 0; }
628
+ </style>
629
+ </head>
630
+ <body>
631
+ <div id="app">
632
+ <div id="chat-pane">
633
+ <header><span>💬 ${session.title}</span></header>
634
+ <div id="messages"></div>
635
+ <div id="compose">
636
+ <div id="pending-attachments"></div>
637
+ <div id="compose-row">
638
+ <button id="attach-btn" type="button" title="Attach image">📎</button>
639
+ <input id="file-input" type="file" accept="image/*" multiple style="display:none">
640
+ <textarea id="msg-input" placeholder="Write a message... (Enter to send, Shift+Enter for newline)"></textarea>
641
+ <button id="send-btn" type="button">Send</button>
642
+ </div>
643
+ </div>
644
+ </div>
645
+ <div id="divider"></div>
646
+ <div id="files-pane">
647
+ <header id="files-header"><span>📂 Files</span><span>${session.dir}</span></header>
648
+ <div id="tree"></div>
649
+ <div id="preview"></div>
650
+ </div>
651
+ </div>
652
+ <script>
653
+ const BOOTSTRAP = ${bootstrap};
654
+ (function () {
655
+ const state = { pending: [], treeCache: {} };
656
+
657
+ const app = document.getElementById('app');
658
+ const messagesEl = document.getElementById('messages');
659
+ const input = document.getElementById('msg-input');
660
+ const sendBtn = document.getElementById('send-btn');
661
+ const attachBtn = document.getElementById('attach-btn');
662
+ const fileInput = document.getElementById('file-input');
663
+ const pendingEl = document.getElementById('pending-attachments');
664
+ const treeEl = document.getElementById('tree');
665
+ const previewEl = document.getElementById('preview');
666
+ const divider = document.getElementById('divider');
667
+
668
+ function applySplit(ratio) {
669
+ app.style.setProperty('--chat-w', (ratio * 100) + '%');
670
+ try { localStorage.setItem('tyr-chat-split', String(ratio)); } catch (e) {}
671
+ }
672
+
673
+ let savedSplit = NaN;
674
+ try { savedSplit = parseFloat(localStorage.getItem('tyr-chat-split')); } catch (e) {}
675
+ applySplit(!isNaN(savedSplit) ? savedSplit : BOOTSTRAP.splitRatio);
676
+
677
+ let dragging = false;
678
+ divider.addEventListener('mousedown', function () { dragging = true; document.body.style.cursor = 'col-resize'; });
679
+ window.addEventListener('mousemove', function (e) {
680
+ if (!dragging) return;
681
+ const ratio = Math.min(0.8, Math.max(0.2, e.clientX / window.innerWidth));
682
+ applySplit(ratio);
683
+ });
684
+ window.addEventListener('mouseup', function () { dragging = false; document.body.style.cursor = ''; });
685
+
686
+ function escapeHtml(s) {
687
+ return String(s).replace(/[&<>"']/g, function (c) {
688
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
689
+ });
690
+ }
691
+
692
+ function renderMessage(msg) {
693
+ const div = document.createElement('div');
694
+ div.className = 'msg ' + (msg.role === 'user' ? 'user' : (msg.role === 'error' ? 'error' : 'assistant'));
695
+ div.innerHTML = escapeHtml(msg.text || '').replace(/\\n/g, '<br>');
696
+ if (msg.attachments && msg.attachments.length) {
697
+ const wrap = document.createElement('div');
698
+ wrap.className = 'attachments';
699
+ msg.attachments.forEach(function (a) {
700
+ const img = document.createElement('img');
701
+ img.src = '/api/attachment?id=' + encodeURIComponent(a.id);
702
+ img.title = a.filename;
703
+ wrap.appendChild(img);
704
+ });
705
+ div.appendChild(wrap);
706
+ }
707
+ messagesEl.appendChild(div);
708
+ messagesEl.scrollTop = messagesEl.scrollHeight;
709
+ }
710
+
711
+ function loadHistory() {
712
+ fetch('/api/history').then(function (r) { return r.json(); }).then(function (data) {
713
+ messagesEl.innerHTML = '';
714
+ (data.history || []).forEach(renderMessage);
715
+ });
716
+ }
717
+
718
+ function renderPending() {
719
+ pendingEl.innerHTML = '';
720
+ state.pending.forEach(function (a) {
721
+ const chip = document.createElement('div');
722
+ chip.className = 'chip';
723
+ const img = document.createElement('img');
724
+ img.src = a.url;
725
+ chip.appendChild(img);
726
+ const btn = document.createElement('button');
727
+ btn.type = 'button';
728
+ btn.textContent = '\\u00d7';
729
+ btn.onclick = function () {
730
+ state.pending = state.pending.filter(function (p) { return p.id !== a.id; });
731
+ renderPending();
732
+ };
733
+ chip.appendChild(btn);
734
+ pendingEl.appendChild(chip);
735
+ });
736
+ }
737
+
738
+ function fileToBase64(file) {
739
+ return new Promise(function (resolve, reject) {
740
+ const reader = new FileReader();
741
+ reader.onload = function () { resolve(String(reader.result).split(',')[1] || ''); };
742
+ reader.onerror = reject;
743
+ reader.readAsDataURL(file);
744
+ });
745
+ }
746
+
747
+ attachBtn.addEventListener('click', function () { fileInput.click(); });
748
+ fileInput.addEventListener('change', function () {
749
+ const files = Array.prototype.slice.call(fileInput.files || []);
750
+ fileInput.value = '';
751
+ Promise.all(files.map(function (file) {
752
+ return fileToBase64(file).then(function (dataBase64) {
753
+ return fetch('/api/upload', {
754
+ method: 'POST',
755
+ headers: { 'Content-Type': 'application/json' },
756
+ body: JSON.stringify({ filename: file.name, mimeType: file.type, dataBase64: dataBase64 }),
757
+ }).then(function (r) { return r.json(); }).then(function (data) {
758
+ if (data.attachment) {
759
+ state.pending.push({
760
+ id: data.attachment.id,
761
+ filename: data.attachment.filename,
762
+ url: '/api/attachment?id=' + data.attachment.id,
763
+ });
764
+ }
765
+ });
766
+ });
767
+ })).then(renderPending);
768
+ });
769
+
770
+ function sendMessage() {
771
+ const text = input.value.trim();
772
+ if (!text && state.pending.length === 0) return;
773
+
774
+ const attachmentIds = state.pending.map(function (p) { return p.id; });
775
+ renderMessage({ role: 'user', text: text, attachments: state.pending.map(function (p) { return { id: p.id, filename: p.filename }; }) });
776
+ input.value = '';
777
+ state.pending = [];
778
+ renderPending();
779
+ sendBtn.disabled = true;
780
+
781
+ fetch('/api/message', {
782
+ method: 'POST',
783
+ headers: { 'Content-Type': 'application/json' },
784
+ body: JSON.stringify({ text: text, attachmentIds: attachmentIds }),
785
+ }).then(function (r) {
786
+ return r.json().then(function (data) { return { ok: r.ok, data: data }; });
787
+ }).then(function (result) {
788
+ if (!result.ok) throw new Error((result.data && result.data.error) || 'Request failed');
789
+ renderMessage(result.data.message);
790
+ }).catch(function (e) {
791
+ renderMessage({ role: 'error', text: 'Error: ' + e.message, attachments: [] });
792
+ }).then(function () {
793
+ sendBtn.disabled = false;
794
+ input.focus();
795
+ });
796
+ }
797
+
798
+ sendBtn.addEventListener('click', sendMessage);
799
+ input.addEventListener('keydown', function (e) {
800
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
801
+ });
802
+
803
+ function folderIcon(isDir) { return isDir ? '\\ud83d\\udcc1' : '\\ud83d\\udcc4'; }
804
+
805
+ function fetchChildren(relPath) {
806
+ if (state.treeCache[relPath]) return Promise.resolve(state.treeCache[relPath]);
807
+ return fetch('/api/tree?path=' + encodeURIComponent(relPath)).then(function (r) { return r.json(); }).then(function (data) {
808
+ state.treeCache[relPath] = data.entries || [];
809
+ return state.treeCache[relPath];
810
+ });
811
+ }
812
+
813
+ function buildNode(entry, container) {
814
+ const row = document.createElement('div');
815
+ row.className = 'tree-row';
816
+ row.textContent = folderIcon(entry.isDir) + ' ' + entry.name;
817
+ container.appendChild(row);
818
+
819
+ if (entry.isDir) {
820
+ const childrenEl = document.createElement('div');
821
+ childrenEl.className = 'tree-children';
822
+ container.appendChild(childrenEl);
823
+ let loaded = false;
824
+ row.addEventListener('click', function () {
825
+ const isOpen = childrenEl.classList.toggle('open');
826
+ if (isOpen && !loaded) {
827
+ loaded = true;
828
+ fetchChildren(entry.path).then(function (children) {
829
+ children.forEach(function (child) { buildNode(child, childrenEl); });
830
+ });
831
+ }
832
+ });
833
+ } else {
834
+ row.addEventListener('click', function () { openFile(entry.path); });
835
+ }
836
+ }
837
+
838
+ function loadTree() {
839
+ treeEl.innerHTML = '';
840
+ fetchChildren('').then(function (entries) {
841
+ entries.forEach(function (entry) { buildNode(entry, treeEl); });
842
+ });
843
+ }
844
+
845
+ function openFile(relPath) {
846
+ fetch('/api/file?path=' + encodeURIComponent(relPath)).then(function (r) { return r.json(); }).then(function (data) {
847
+ previewEl.innerHTML = '';
848
+ const title = document.createElement('div');
849
+ title.style.marginBottom = '8px';
850
+ title.style.color = '#4db8ff';
851
+ title.textContent = relPath + (data.truncated ? ' (truncated preview)' : '');
852
+ previewEl.appendChild(title);
853
+
854
+ if (data.isImage) {
855
+ const img = document.createElement('img');
856
+ img.src = '/api/raw?path=' + encodeURIComponent(relPath);
857
+ previewEl.appendChild(img);
858
+ } else if (data.binary) {
859
+ const p = document.createElement('div');
860
+ p.textContent = 'Binary file (' + data.size + ' bytes) \\u2014 no preview available.';
861
+ previewEl.appendChild(p);
862
+ } else {
863
+ const pre = document.createElement('pre');
864
+ pre.textContent = data.content || '';
865
+ previewEl.appendChild(pre);
866
+ }
867
+ });
868
+ }
869
+
870
+ loadHistory();
871
+ loadTree();
872
+ input.focus();
873
+ })();
874
+ </script>
875
+ </body>
876
+ </html>`;
877
+ }
878
+ }
879
+
880
+ export const ChatManagerTests = {};