pi-tau-web-server 1.0.8

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.
Files changed (98) hide show
  1. package/README.md +303 -0
  2. package/bin/auth.js +96 -0
  3. package/bin/config.js +99 -0
  4. package/bin/model-utils.js +134 -0
  5. package/bin/server-main.js +1189 -0
  6. package/bin/sessions.js +492 -0
  7. package/bin/tau.js +8 -0
  8. package/bin/tree.js +248 -0
  9. package/bin/types.js +2 -0
  10. package/package.json +74 -0
  11. package/public/app-main.js +2025 -0
  12. package/public/app-types.js +1 -0
  13. package/public/app.js +1 -0
  14. package/public/command-palette.js +42 -0
  15. package/public/dialogs.js +199 -0
  16. package/public/file-browser.js +196 -0
  17. package/public/icons/apple-touch-icon.png +0 -0
  18. package/public/icons/favicon-16.png +0 -0
  19. package/public/icons/favicon-32.png +0 -0
  20. package/public/icons/tau-192.png +0 -0
  21. package/public/icons/tau-512.png +0 -0
  22. package/public/icons/tau-logo.png +0 -0
  23. package/public/icons/tau-logo.svg +13 -0
  24. package/public/icons/tau-maskable-512.png +0 -0
  25. package/public/icons/tau-new.png +0 -0
  26. package/public/index.html +264 -0
  27. package/public/launcher-panel.js +54 -0
  28. package/public/launcher.js +84 -0
  29. package/public/manifest.json +28 -0
  30. package/public/markdown.js +336 -0
  31. package/public/message-renderer.js +268 -0
  32. package/public/model-picker.js +478 -0
  33. package/public/session-sidebar.js +460 -0
  34. package/public/session-stats-card.js +123 -0
  35. package/public/state.js +81 -0
  36. package/public/style.css +3864 -0
  37. package/public/sw.js +66 -0
  38. package/public/themes.js +72 -0
  39. package/public/tool-card.js +317 -0
  40. package/public/tree-view.js +474 -0
  41. package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  42. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  43. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  44. package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  45. package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  46. package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  48. package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  49. package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  50. package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  51. package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  52. package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  53. package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  55. package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  57. package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  58. package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  59. package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  60. package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  61. package/public/vendor/katex/katex.min.css +1 -0
  62. package/public/vendor/katex/katex.min.js +1 -0
  63. package/public/voice-input.js +74 -0
  64. package/public/websocket-client.js +156 -0
  65. package/scripts/copy-katex.mjs +32 -0
  66. package/src/pi-extension/tau-tree.ts +71 -0
  67. package/src/public/app-main.ts +2190 -0
  68. package/src/public/app-types.ts +96 -0
  69. package/src/public/app.ts +1 -0
  70. package/src/public/command-palette.ts +53 -0
  71. package/src/public/dialogs.ts +251 -0
  72. package/src/public/file-browser.ts +224 -0
  73. package/src/public/launcher-panel.ts +68 -0
  74. package/src/public/launcher.ts +101 -0
  75. package/src/public/legacy-dom.d.ts +29 -0
  76. package/src/public/markdown.ts +372 -0
  77. package/src/public/message-renderer.ts +311 -0
  78. package/src/public/model-picker.ts +500 -0
  79. package/src/public/session-sidebar.ts +522 -0
  80. package/src/public/session-stats-card.ts +176 -0
  81. package/src/public/state.ts +96 -0
  82. package/src/public/sw.ts +79 -0
  83. package/src/public/themes.ts +73 -0
  84. package/src/public/tool-card.ts +375 -0
  85. package/src/public/tree-view.ts +527 -0
  86. package/src/public/voice-input.ts +98 -0
  87. package/src/public/websocket-client.ts +165 -0
  88. package/src/server/auth.ts +88 -0
  89. package/src/server/config.ts +88 -0
  90. package/src/server/model-utils.ts +122 -0
  91. package/src/server/server-main.ts +1004 -0
  92. package/src/server/sessions.ts +481 -0
  93. package/src/server/tau.ts +9 -0
  94. package/src/server/tree.ts +288 -0
  95. package/src/server/types.ts +68 -0
  96. package/tsconfig.json +3 -0
  97. package/tsconfig.public.json +15 -0
  98. package/tsconfig.server.json +16 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Lightweight Markdown renderer — no dependencies.
3
+ * Handles: headings, bold, italic, inline code, code blocks with language,
4
+ * links, unordered/ordered lists, blockquotes, horizontal rules, tables,
5
+ * task lists, images, paragraphs.
6
+ */
7
+ export function renderMarkdown(text) {
8
+ if (!text)
9
+ return '';
10
+ // Normalize line endings
11
+ text = text.replace(/\r\n/g, '\n');
12
+ // Extract code blocks first to protect them
13
+ const codeBlocks = [];
14
+ text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
15
+ const idx = codeBlocks.length;
16
+ codeBlocks.push({ lang, code: code.replace(/\n$/, '') });
17
+ return `%%CODEBLOCK_${idx}%%`;
18
+ });
19
+ // Extract display math after code fences (so $$ inside fences stays literal)
20
+ // but before the line split, since $$...$$ may span multiple lines.
21
+ const mathBlocks = [];
22
+ text = extractDisplayMath(text, mathBlocks);
23
+ // Split into lines and process block-level elements
24
+ const lines = text.split('\n');
25
+ let html = '';
26
+ let inList = false;
27
+ let listType = '';
28
+ let inBlockquote = false;
29
+ let blockquoteLines = [];
30
+ function flushBlockquote() {
31
+ if (inBlockquote) {
32
+ html += '<blockquote>' + blockquoteLines.map(l => renderInline(l)).join('<br>') + '</blockquote>';
33
+ inBlockquote = false;
34
+ blockquoteLines = [];
35
+ }
36
+ }
37
+ function flushList() {
38
+ if (inList) {
39
+ html += `</${listType}>`;
40
+ inList = false;
41
+ }
42
+ }
43
+ // Check if a line is a table separator (e.g. |---|---|)
44
+ function isTableSeparator(line) {
45
+ return /^\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
46
+ }
47
+ // Check if a line looks like a table row
48
+ function isTableRow(line) {
49
+ return line.trim().startsWith('|') && line.trim().endsWith('|');
50
+ }
51
+ // Parse alignment from separator row
52
+ function parseAlignments(line) {
53
+ return line.split('|').filter((c) => c.trim()).map((cell) => {
54
+ const trimmed = cell.trim();
55
+ if (trimmed.startsWith(':') && trimmed.endsWith(':'))
56
+ return 'center';
57
+ if (trimmed.endsWith(':'))
58
+ return 'right';
59
+ return 'left';
60
+ });
61
+ }
62
+ for (let i = 0; i < lines.length; i++) {
63
+ let line = lines[i];
64
+ // Code block placeholder
65
+ const codeMatch = line.match(/^%%CODEBLOCK_(\d+)%%$/);
66
+ if (codeMatch) {
67
+ flushList();
68
+ flushBlockquote();
69
+ const block = codeBlocks[parseInt(codeMatch[1])];
70
+ const langLabel = block.lang || 'code';
71
+ html += `<div class="code-block-wrapper">`;
72
+ html += `<div class="code-block-header"><span>${escapeHtml(langLabel)}</span><button class="copy-btn" onclick="copyCode(this)">Copy</button></div>`;
73
+ html += `<pre><code>${escapeHtml(block.code)}</code></pre></div>`;
74
+ continue;
75
+ }
76
+ // Display math placeholder
77
+ const mathMatch = line.match(/^%%MATHBLOCK(\d+)%%$/);
78
+ if (mathMatch) {
79
+ flushList();
80
+ flushBlockquote();
81
+ html += `<div class="math-block">${mathBlocks[parseInt(mathMatch[1])]}</div>`;
82
+ continue;
83
+ }
84
+ // Table detection: look ahead for header + separator pattern
85
+ if (isTableRow(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
86
+ flushList();
87
+ flushBlockquote();
88
+ const alignments = parseAlignments(lines[i + 1]);
89
+ // Parse header
90
+ const headerCells = line.split('|').filter((c) => c.trim() !== '' || line.trim() === '|');
91
+ // More robust: split between first and last pipe
92
+ const headerRow = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
93
+ html += '<div class="table-wrapper"><table><thead><tr>';
94
+ headerRow.forEach((cell, idx) => {
95
+ const align = alignments[idx] || 'left';
96
+ html += `<th style="text-align:${align}">${renderInline(cell.trim())}</th>`;
97
+ });
98
+ html += '</tr></thead><tbody>';
99
+ // Skip separator
100
+ i += 2;
101
+ // Parse body rows
102
+ while (i < lines.length && isTableRow(lines[i])) {
103
+ const rowCells = lines[i].trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
104
+ html += '<tr>';
105
+ rowCells.forEach((cell, idx) => {
106
+ const align = alignments[idx] || 'left';
107
+ html += `<td style="text-align:${align}">${renderInline(cell.trim())}</td>`;
108
+ });
109
+ html += '</tr>';
110
+ i++;
111
+ }
112
+ html += '</tbody></table></div>';
113
+ i--; // back up since the for loop will increment
114
+ continue;
115
+ }
116
+ // Horizontal rule
117
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
118
+ flushList();
119
+ flushBlockquote();
120
+ html += '<hr>';
121
+ continue;
122
+ }
123
+ // Headings
124
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
125
+ if (headingMatch) {
126
+ flushList();
127
+ flushBlockquote();
128
+ const level = headingMatch[1].length;
129
+ html += `<h${level}>${renderInline(headingMatch[2])}</h${level}>`;
130
+ continue;
131
+ }
132
+ // Blockquote — handle `>` with or without trailing space, and empty `>` lines
133
+ if (/^>\s?/.test(line)) {
134
+ flushList();
135
+ if (!inBlockquote) {
136
+ inBlockquote = true;
137
+ blockquoteLines = [];
138
+ }
139
+ const content = line.replace(/^>\s?/, '');
140
+ if (content === '') {
141
+ // Empty blockquote line acts as paragraph break within quote
142
+ blockquoteLines.push('');
143
+ }
144
+ else {
145
+ blockquoteLines.push(content);
146
+ }
147
+ continue;
148
+ }
149
+ else if (inBlockquote) {
150
+ flushBlockquote();
151
+ }
152
+ // Task list (must check before regular list)
153
+ const taskMatch = line.match(/^(\s*)[*\-+]\s+\[([ xX])\]\s+(.+)$/);
154
+ if (taskMatch) {
155
+ if (!inList || listType !== 'ul') {
156
+ flushList();
157
+ html += '<ul class="task-list">';
158
+ inList = true;
159
+ listType = 'ul';
160
+ }
161
+ const checked = taskMatch[2] !== ' ';
162
+ html += `<li class="task-list-item"><input type="checkbox" disabled ${checked ? 'checked' : ''}> ${renderInline(taskMatch[3])}</li>`;
163
+ continue;
164
+ }
165
+ // Unordered list
166
+ const ulMatch = line.match(/^(\s*)[*\-+]\s+(.+)$/);
167
+ if (ulMatch) {
168
+ flushBlockquote();
169
+ if (!inList || listType !== 'ul') {
170
+ if (inList)
171
+ html += `</${listType}>`;
172
+ html += '<ul>';
173
+ inList = true;
174
+ listType = 'ul';
175
+ }
176
+ html += `<li>${renderInline(ulMatch[2])}</li>`;
177
+ continue;
178
+ }
179
+ // Ordered list
180
+ const olMatch = line.match(/^(\s*)\d+\.\s+(.+)$/);
181
+ if (olMatch) {
182
+ flushBlockquote();
183
+ if (!inList || listType !== 'ol') {
184
+ if (inList)
185
+ html += `</${listType}>`;
186
+ html += '<ol>';
187
+ inList = true;
188
+ listType = 'ol';
189
+ }
190
+ html += `<li>${renderInline(olMatch[2])}</li>`;
191
+ continue;
192
+ }
193
+ // Close list if we're out of list items
194
+ flushList();
195
+ // Empty line
196
+ if (line.trim() === '') {
197
+ continue;
198
+ }
199
+ // Regular paragraph
200
+ html += `<p>${renderInline(line)}</p>`;
201
+ }
202
+ // Close any open blocks
203
+ flushList();
204
+ flushBlockquote();
205
+ return html;
206
+ }
207
+ /**
208
+ * Lightweight user-message renderer — inline formatting + blockquotes only.
209
+ * Preserves whitespace/newlines for everything else.
210
+ */
211
+ export function renderUserMarkdown(text) {
212
+ if (!text)
213
+ return '';
214
+ text = text.replace(/\r\n/g, '\n');
215
+ const mathBlocks = [];
216
+ text = extractDisplayMath(text, mathBlocks);
217
+ const lines = text.split('\n');
218
+ let html = '';
219
+ let inBlockquote = false;
220
+ let bqLines = [];
221
+ function flushBq() {
222
+ if (inBlockquote) {
223
+ html += '<blockquote>' + bqLines.map(l => renderInline(l)).join('<br>') + '</blockquote>';
224
+ inBlockquote = false;
225
+ bqLines = [];
226
+ }
227
+ }
228
+ for (const line of lines) {
229
+ const mathMatch = line.match(/^%%MATHBLOCK(\d+)%%$/);
230
+ if (mathMatch) {
231
+ flushBq();
232
+ html += `<div class="math-block">${mathBlocks[parseInt(mathMatch[1])]}</div>`;
233
+ continue;
234
+ }
235
+ if (/^>\s?/.test(line)) {
236
+ if (!inBlockquote) {
237
+ inBlockquote = true;
238
+ bqLines = [];
239
+ }
240
+ bqLines.push(line.replace(/^>\s?/, ''));
241
+ continue;
242
+ }
243
+ flushBq();
244
+ html += renderInline(line) + '\n';
245
+ }
246
+ flushBq();
247
+ return html.replace(/\n$/, '');
248
+ }
249
+ function renderInline(text) {
250
+ // Inline code (must come first to protect content)
251
+ const codeSpans = [];
252
+ text = text.replace(/`([^`]+)`/g, (_, code) => {
253
+ const idx = codeSpans.length;
254
+ codeSpans.push(`<code>${escapeHtml(code)}</code>`);
255
+ return `%%ICODE${idx}%%`;
256
+ });
257
+ // Inline math — after inline code (so `$x$` stays code) but before the
258
+ // emphasis regexes, which would otherwise mangle math like $a_i * b_i$.
259
+ const mathSpans = [];
260
+ const putMath = (src) => `%%IMATH${mathSpans.push(renderMath(src.trim(), false)) - 1}%%`;
261
+ text = text.replace(/\\\((.+?)\\\)/g, (_, src) => putMath(src));
262
+ // Single-$ form with currency heuristics: opening $ not escaped/doubled and
263
+ // not followed by whitespace; closing $ not escaped, not preceded by
264
+ // whitespace, not followed by a digit ("$5 and $10" stays text).
265
+ text = text.replace(/(?<![\\$])\$(?!\s)((?:\\.|[^$])+?)(?<![\s\\])\$(?!\d)/g, (_, src) => putMath(src));
266
+ // Images (before links so ![...](...) isn't caught by link regex)
267
+ text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="inline-image">');
268
+ // Bold + italic
269
+ text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
270
+ // Bold
271
+ text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
272
+ text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
273
+ // Italic
274
+ text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
275
+ text = text.replace(/_(.+?)_/g, '<em>$1</em>');
276
+ // Strikethrough
277
+ text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
278
+ // Links
279
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
280
+ // Auto-link bare URLs
281
+ text = text.replace(/(^|[^"'])(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
282
+ // Restore inline math and inline code last so the regexes above never see
283
+ // the generated HTML.
284
+ text = text.replace(/%%IMATH(\d+)%%/g, (_, idx) => mathSpans[parseInt(idx)]);
285
+ text = text.replace(/%%ICODE(\d+)%%/g, (_, idx) => codeSpans[parseInt(idx)]);
286
+ return text;
287
+ }
288
+ function renderMath(src, displayMode) {
289
+ const katex = globalThis.katex;
290
+ if (!katex)
291
+ return `<code class="math-fallback">${escapeHtml(src)}</code>`;
292
+ try {
293
+ return katex.renderToString(src, { displayMode, throwOnError: false });
294
+ }
295
+ catch {
296
+ return `<code class="math-fallback">${escapeHtml(src)}</code>`;
297
+ }
298
+ }
299
+ // Replaces $$...$$ and \[...\] segments with %%MATHBLOCK<N>%% placeholders on
300
+ // their own lines, pushing the KaTeX-rendered HTML into `blocks`. The
301
+ // placeholder deliberately has no underscore (matching %%ICODE0%%) so the
302
+ // italic regex can never split it.
303
+ function extractDisplayMath(text, blocks) {
304
+ const put = (_, src) => `\n%%MATHBLOCK${blocks.push(renderMath(src.trim(), true)) - 1}%%\n`;
305
+ text = text.replace(/\$\$([\s\S]+?)\$\$/g, put);
306
+ text = text.replace(/\\\[([\s\S]+?)\\\]/g, put);
307
+ return text;
308
+ }
309
+ function escapeHtml(text) {
310
+ return text
311
+ .replace(/&/g, '&amp;')
312
+ .replace(/</g, '&lt;')
313
+ .replace(/>/g, '&gt;')
314
+ .replace(/"/g, '&quot;');
315
+ }
316
+ // Global copy function for code blocks. Guarded so the module can be imported
317
+ // under node --test, where there is no window.
318
+ if (typeof window !== 'undefined') {
319
+ window.copyCode = function (btn) {
320
+ const wrapper = btn.closest('.code-block-wrapper');
321
+ if (!wrapper)
322
+ return;
323
+ const codeBlock = wrapper.querySelector('code');
324
+ if (!codeBlock)
325
+ return;
326
+ const text = codeBlock.textContent || '';
327
+ navigator.clipboard.writeText(text).then(() => {
328
+ btn.textContent = 'Copied!';
329
+ btn.classList.add('copied');
330
+ setTimeout(() => {
331
+ btn.textContent = 'Copy';
332
+ btn.classList.remove('copied');
333
+ }, 2000);
334
+ });
335
+ };
336
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Message Renderer - Renders chat messages with markdown support
3
+ */
4
+ import { renderMarkdown, renderUserMarkdown } from './markdown.js';
5
+ export class MessageRenderer {
6
+ container;
7
+ isNearBottom;
8
+ constructor(container) {
9
+ this.container = container;
10
+ this.isNearBottom = true;
11
+ // Track scroll position for smart auto-scroll
12
+ this.container.addEventListener('scroll', () => {
13
+ const threshold = 100;
14
+ this.isNearBottom =
15
+ this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < threshold;
16
+ });
17
+ }
18
+ clear() {
19
+ this.container.innerHTML = '';
20
+ }
21
+ renderWelcome() {
22
+ this.container.innerHTML = `
23
+ <div class="welcome">
24
+ <div class="welcome-icon"><img src="icons/tau-192.png" alt="τ" class="tau-icon-welcome"></div>
25
+ <p>Welcome to Tau</p>
26
+ <p class="hint">Type a message below to start chatting with Pi, or select a session from the sidebar.</p>
27
+ <div class="shortcuts-hint">
28
+ <span>/ Focus input</span>
29
+ <span>Esc Abort</span>
30
+ </div>
31
+ </div>
32
+ `;
33
+ }
34
+ renderUserMessage(message, isHistory = false) {
35
+ // Remove welcome message if present
36
+ const welcome = this.container.querySelector('.welcome');
37
+ if (welcome)
38
+ welcome.remove();
39
+ const div = document.createElement('div');
40
+ div.className = `message user${isHistory ? ' history' : ''}`;
41
+ let imagesHtml = '';
42
+ if (message.images && message.images.length > 0) {
43
+ imagesHtml = '<div class="message-images">' +
44
+ message.images.map(img => {
45
+ const src = img.data.startsWith('data:') ? img.data : `data:${img.mimeType || 'image/png'};base64,${img.data}`;
46
+ return `<img class="message-image" src="${src}" alt="Attached image" />`;
47
+ }).join('') +
48
+ '</div>';
49
+ }
50
+ div.innerHTML = `
51
+ <div class="message-content">${imagesHtml}${renderUserMarkdown(message.content)}</div>
52
+ <button class="message-copy-btn" aria-label="Copy message"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
53
+ `;
54
+ this._setupCopyBtn(div);
55
+ this.container.appendChild(div);
56
+ if (!isHistory)
57
+ this.scrollToBottom();
58
+ }
59
+ renderAssistantMessage(message, isStreaming = false, isHistory = false) {
60
+ // Remove welcome message if present
61
+ const welcome = this.container.querySelector('.welcome');
62
+ if (welcome)
63
+ welcome.remove();
64
+ const div = document.createElement('div');
65
+ div.className = `message assistant${isHistory ? ' history' : ''}`;
66
+ div.dataset.messageId = message.id || 'streaming';
67
+ let contentHtml = '';
68
+ let usageHtml = '';
69
+ if (typeof message.content === 'string') {
70
+ contentHtml = isStreaming ? this.escapeHtml(message.content) : renderMarkdown(message.content);
71
+ }
72
+ else if (Array.isArray(message.content)) {
73
+ for (const block of message.content) {
74
+ if (block.type === 'text') {
75
+ contentHtml += isStreaming ? this.escapeHtml(block.text) : renderMarkdown(block.text || '');
76
+ }
77
+ else if (block.type === 'thinking') {
78
+ contentHtml += this.renderThinkingBlock(block.thinking);
79
+ }
80
+ }
81
+ }
82
+ // Usage/cost info
83
+ if (message.usage && message.usage.cost) {
84
+ const cost = message.usage.cost.total;
85
+ if (cost && cost > 0) {
86
+ usageHtml = `<span class="message-usage">$${cost.toFixed(4)}</span>`;
87
+ }
88
+ }
89
+ const streamingClass = isStreaming ? ' streaming' : '';
90
+ div.innerHTML = `
91
+ <div class="message-content${streamingClass}">${contentHtml}</div>
92
+ ${usageHtml}
93
+ ${!isStreaming ? '<button class="message-copy-btn" aria-label="Copy message"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>' : ''}
94
+ `;
95
+ if (!isStreaming)
96
+ this._setupCopyBtn(div);
97
+ this.container.appendChild(div);
98
+ if (!isHistory)
99
+ this.scrollToBottom();
100
+ return div;
101
+ }
102
+ renderThinkingBlock(thinking) {
103
+ const id = 'thinking-' + Math.random().toString(36).slice(2, 8);
104
+ return `<div class="thinking-block">
105
+ <div class="thinking-toggle" onclick="var c=document.getElementById('${id}');c.classList.toggle('expanded');this.classList.toggle('expanded')">
106
+ <span class="chevron"><svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor"><path d="M2 1l4 3-4 3z"/></svg></span>
107
+ <span class="thinking-label"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M12 5v13"/><path d="M6.5 9h11"/><path d="M7 13h10"/></svg> Thinking</span>
108
+ </div>
109
+ <div class="thinking-content" id="${id}">${this.escapeHtml(thinking)}</div>
110
+ </div>`;
111
+ }
112
+ updateStreamingThinking(messageElement, thinking) {
113
+ let thinkingDiv = messageElement.querySelector('.streaming-thinking');
114
+ if (!thinkingDiv) {
115
+ const contentDiv = messageElement.querySelector('.message-content');
116
+ if (!contentDiv)
117
+ return;
118
+ thinkingDiv = document.createElement('div');
119
+ thinkingDiv.className = 'thinking-block streaming-thinking';
120
+ thinkingDiv.innerHTML = `
121
+ <div class="thinking-toggle expanded" onclick="var c=this.nextElementSibling;c.classList.toggle('expanded');this.classList.toggle('expanded')">
122
+ <span class="chevron"><svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor"><path d="M2 1l4 3-4 3z"/></svg></span>
123
+ <span class="thinking-label"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M12 5v13"/><path d="M6.5 9h11"/><path d="M7 13h10"/></svg> Thinking</span>
124
+ </div>
125
+ <div class="thinking-content expanded"></div>`;
126
+ contentDiv.prepend(thinkingDiv);
127
+ }
128
+ const contentEl = thinkingDiv.querySelector('.thinking-content');
129
+ if (contentEl) {
130
+ contentEl.textContent = thinking;
131
+ this.scrollToBottom();
132
+ }
133
+ }
134
+ updateStreamingMessage(messageElement, content) {
135
+ const contentDiv = messageElement.querySelector('.message-content');
136
+ if (!contentDiv)
137
+ return;
138
+ // Render markdown incrementally so headings, lists, inline formatting,
139
+ // and other block elements appear live — not just after streaming ends.
140
+ const rendered = renderMarkdown(content);
141
+ const thinkingBlock = contentDiv.querySelector('.streaming-thinking');
142
+ if (thinkingBlock) {
143
+ // Keep the thinking block; update only the text portion
144
+ let textNode = contentDiv.querySelector('.streaming-text');
145
+ if (!textNode) {
146
+ // First time text arrives after thinking started:
147
+ // remove any stale text nodes that were placed directly in contentDiv
148
+ // before the thinking block existed.
149
+ let node = thinkingBlock.nextSibling;
150
+ while (node) {
151
+ const next = node.nextSibling;
152
+ node.remove();
153
+ node = next;
154
+ }
155
+ textNode = document.createElement('div');
156
+ textNode.className = 'streaming-text';
157
+ contentDiv.appendChild(textNode);
158
+ }
159
+ textNode.innerHTML = rendered;
160
+ // Stash the raw markdown so finalizeStreamingMessage can do a clean
161
+ // re-render without scraping textContent from already-rendered HTML.
162
+ textNode.dataset.rawText = content;
163
+ }
164
+ else {
165
+ contentDiv.innerHTML = rendered;
166
+ contentDiv.dataset.rawText = content;
167
+ }
168
+ this.scrollToBottom();
169
+ }
170
+ finalizeStreamingMessage(messageElement, usage = null, thinking = '') {
171
+ const contentDiv = messageElement.querySelector('.message-content');
172
+ if (contentDiv) {
173
+ contentDiv.classList.remove('streaming');
174
+ // Recover the raw markdown text we stashed during streaming updates.
175
+ // Falls back to textContent (which loses formatting markers) only if
176
+ // the dataset is somehow missing.
177
+ const streamingText = contentDiv.querySelector('.streaming-text');
178
+ const rawText = (streamingText && streamingText.dataset.rawText) ||
179
+ contentDiv.dataset.rawText ||
180
+ contentDiv.textContent ||
181
+ '';
182
+ // Final render — catches edge cases like code blocks whose closing
183
+ // fence arrived on the very last delta.
184
+ let html = '';
185
+ if (thinking) {
186
+ html += this.renderThinkingBlock(thinking);
187
+ }
188
+ html += renderMarkdown(rawText);
189
+ contentDiv.innerHTML = html;
190
+ }
191
+ // Add copy button after streaming finishes
192
+ if (!messageElement.querySelector('.message-copy-btn')) {
193
+ const btn = document.createElement('button');
194
+ btn.className = 'message-copy-btn';
195
+ btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
196
+ messageElement.appendChild(btn);
197
+ this._setupCopyBtn(messageElement);
198
+ }
199
+ // Add usage info if available
200
+ if (usage && usage.cost) {
201
+ const total = usage.cost.total;
202
+ if (total && total > 0) {
203
+ if (!messageElement.querySelector('.message-usage')) {
204
+ const span = document.createElement('span');
205
+ span.className = 'message-usage';
206
+ span.textContent = `$${total.toFixed(4)}`;
207
+ messageElement.appendChild(span);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ renderSystemMessage(text) {
213
+ const div = document.createElement('div');
214
+ div.className = 'system-message';
215
+ div.textContent = String(text ?? '');
216
+ this.container.appendChild(div);
217
+ this.scrollToBottom();
218
+ }
219
+ renderError(errorMessage) {
220
+ const div = document.createElement('div');
221
+ div.className = 'error-message';
222
+ div.textContent = `⚠️ ${errorMessage}`;
223
+ this.container.appendChild(div);
224
+ this.scrollToBottom();
225
+ }
226
+ _setupCopyBtn(messageEl) {
227
+ const btn = messageEl.querySelector('.message-copy-btn');
228
+ if (!btn)
229
+ return;
230
+ btn.addEventListener('click', () => {
231
+ const content = messageEl.querySelector('.message-content');
232
+ if (!content)
233
+ return;
234
+ const text = content.textContent;
235
+ // Fallback for non-HTTPS (LAN access)
236
+ const copyText = (t) => {
237
+ if (navigator.clipboard)
238
+ return navigator.clipboard.writeText(t);
239
+ const ta = document.createElement('textarea');
240
+ ta.value = t;
241
+ ta.style.cssText = 'position:fixed;left:-9999px';
242
+ document.body.appendChild(ta);
243
+ ta.select();
244
+ document.execCommand('copy');
245
+ document.body.removeChild(ta);
246
+ return Promise.resolve();
247
+ };
248
+ copyText(text).then(() => {
249
+ btn.classList.add('copied');
250
+ setTimeout(() => {
251
+ btn.classList.remove('copied');
252
+ }, 1500);
253
+ });
254
+ });
255
+ }
256
+ escapeHtml(text) {
257
+ const div = document.createElement('div');
258
+ div.textContent = String(text ?? '');
259
+ return div.innerHTML;
260
+ }
261
+ scrollToBottom() {
262
+ if (this.isNearBottom) {
263
+ requestAnimationFrame(() => {
264
+ this.container.scrollTop = this.container.scrollHeight;
265
+ });
266
+ }
267
+ }
268
+ }