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.
- package/README.md +303 -0
- package/bin/auth.js +96 -0
- package/bin/config.js +99 -0
- package/bin/model-utils.js +134 -0
- package/bin/server-main.js +1189 -0
- package/bin/sessions.js +492 -0
- package/bin/tau.js +8 -0
- package/bin/tree.js +248 -0
- package/bin/types.js +2 -0
- package/package.json +74 -0
- package/public/app-main.js +2025 -0
- package/public/app-types.js +1 -0
- package/public/app.js +1 -0
- package/public/command-palette.js +42 -0
- package/public/dialogs.js +199 -0
- package/public/file-browser.js +196 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/tau-192.png +0 -0
- package/public/icons/tau-512.png +0 -0
- package/public/icons/tau-logo.png +0 -0
- package/public/icons/tau-logo.svg +13 -0
- package/public/icons/tau-maskable-512.png +0 -0
- package/public/icons/tau-new.png +0 -0
- package/public/index.html +264 -0
- package/public/launcher-panel.js +54 -0
- package/public/launcher.js +84 -0
- package/public/manifest.json +28 -0
- package/public/markdown.js +336 -0
- package/public/message-renderer.js +268 -0
- package/public/model-picker.js +478 -0
- package/public/session-sidebar.js +460 -0
- package/public/session-stats-card.js +123 -0
- package/public/state.js +81 -0
- package/public/style.css +3864 -0
- package/public/sw.js +66 -0
- package/public/themes.js +72 -0
- package/public/tool-card.js +317 -0
- package/public/tree-view.js +474 -0
- package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/public/vendor/katex/katex.min.css +1 -0
- package/public/vendor/katex/katex.min.js +1 -0
- package/public/voice-input.js +74 -0
- package/public/websocket-client.js +156 -0
- package/scripts/copy-katex.mjs +32 -0
- package/src/pi-extension/tau-tree.ts +71 -0
- package/src/public/app-main.ts +2190 -0
- package/src/public/app-types.ts +96 -0
- package/src/public/app.ts +1 -0
- package/src/public/command-palette.ts +53 -0
- package/src/public/dialogs.ts +251 -0
- package/src/public/file-browser.ts +224 -0
- package/src/public/launcher-panel.ts +68 -0
- package/src/public/launcher.ts +101 -0
- package/src/public/legacy-dom.d.ts +29 -0
- package/src/public/markdown.ts +372 -0
- package/src/public/message-renderer.ts +311 -0
- package/src/public/model-picker.ts +500 -0
- package/src/public/session-sidebar.ts +522 -0
- package/src/public/session-stats-card.ts +176 -0
- package/src/public/state.ts +96 -0
- package/src/public/sw.ts +79 -0
- package/src/public/themes.ts +73 -0
- package/src/public/tool-card.ts +375 -0
- package/src/public/tree-view.ts +527 -0
- package/src/public/voice-input.ts +98 -0
- package/src/public/websocket-client.ts +165 -0
- package/src/server/auth.ts +88 -0
- package/src/server/config.ts +88 -0
- package/src/server/model-utils.ts +122 -0
- package/src/server/server-main.ts +1004 -0
- package/src/server/sessions.ts +481 -0
- package/src/server/tau.ts +9 -0
- package/src/server/tree.ts +288 -0
- package/src/server/types.ts +68 -0
- package/tsconfig.json +3 -0
- package/tsconfig.public.json +15 -0
- 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, '&')
|
|
312
|
+
.replace(/</g, '<')
|
|
313
|
+
.replace(/>/g, '>')
|
|
314
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|