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,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Renderer - Renders chat messages with markdown support
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renderMarkdown, renderUserMarkdown } from './markdown.js';
|
|
6
|
+
|
|
7
|
+
type RenderedImage = {
|
|
8
|
+
data: string;
|
|
9
|
+
mimeType?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type MessageContentBlock = {
|
|
13
|
+
type?: string;
|
|
14
|
+
text?: string;
|
|
15
|
+
thinking?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type RenderMessage = {
|
|
19
|
+
id?: string;
|
|
20
|
+
content?: string | MessageContentBlock[];
|
|
21
|
+
images?: RenderedImage[];
|
|
22
|
+
usage?: {
|
|
23
|
+
cost?: { total?: number };
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class MessageRenderer {
|
|
29
|
+
container: HTMLElement;
|
|
30
|
+
isNearBottom: boolean;
|
|
31
|
+
|
|
32
|
+
constructor(container: HTMLElement) {
|
|
33
|
+
this.container = container;
|
|
34
|
+
this.isNearBottom = true;
|
|
35
|
+
|
|
36
|
+
// Track scroll position for smart auto-scroll
|
|
37
|
+
this.container.addEventListener('scroll', () => {
|
|
38
|
+
const threshold = 100;
|
|
39
|
+
this.isNearBottom =
|
|
40
|
+
this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < threshold;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clear() {
|
|
45
|
+
this.container.innerHTML = '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
renderWelcome() {
|
|
49
|
+
this.container.innerHTML = `
|
|
50
|
+
<div class="welcome">
|
|
51
|
+
<div class="welcome-icon"><img src="icons/tau-192.png" alt="τ" class="tau-icon-welcome"></div>
|
|
52
|
+
<p>Welcome to Tau</p>
|
|
53
|
+
<p class="hint">Type a message below to start chatting with Pi, or select a session from the sidebar.</p>
|
|
54
|
+
<div class="shortcuts-hint">
|
|
55
|
+
<span>/ Focus input</span>
|
|
56
|
+
<span>Esc Abort</span>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
renderUserMessage(message: RenderMessage, isHistory = false) {
|
|
63
|
+
// Remove welcome message if present
|
|
64
|
+
const welcome = this.container.querySelector('.welcome');
|
|
65
|
+
if (welcome) welcome.remove();
|
|
66
|
+
|
|
67
|
+
const div = document.createElement('div');
|
|
68
|
+
div.className = `message user${isHistory ? ' history' : ''}`;
|
|
69
|
+
|
|
70
|
+
let imagesHtml = '';
|
|
71
|
+
if (message.images && message.images.length > 0) {
|
|
72
|
+
imagesHtml = '<div class="message-images">' +
|
|
73
|
+
message.images.map(img => {
|
|
74
|
+
const src = img.data.startsWith('data:') ? img.data : `data:${img.mimeType || 'image/png'};base64,${img.data}`;
|
|
75
|
+
return `<img class="message-image" src="${src}" alt="Attached image" />`;
|
|
76
|
+
}).join('') +
|
|
77
|
+
'</div>';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
div.innerHTML = `
|
|
81
|
+
<div class="message-content">${imagesHtml}${renderUserMarkdown(message.content as string)}</div>
|
|
82
|
+
<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>
|
|
83
|
+
`;
|
|
84
|
+
this._setupCopyBtn(div);
|
|
85
|
+
this.container.appendChild(div);
|
|
86
|
+
if (!isHistory) this.scrollToBottom();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
renderAssistantMessage(message: RenderMessage, isStreaming = false, isHistory = false) {
|
|
90
|
+
// Remove welcome message if present
|
|
91
|
+
const welcome = this.container.querySelector('.welcome');
|
|
92
|
+
if (welcome) welcome.remove();
|
|
93
|
+
|
|
94
|
+
const div = document.createElement('div');
|
|
95
|
+
div.className = `message assistant${isHistory ? ' history' : ''}`;
|
|
96
|
+
div.dataset.messageId = message.id || 'streaming';
|
|
97
|
+
|
|
98
|
+
let contentHtml = '';
|
|
99
|
+
let usageHtml = '';
|
|
100
|
+
|
|
101
|
+
if (typeof message.content === 'string') {
|
|
102
|
+
contentHtml = isStreaming ? this.escapeHtml(message.content) : renderMarkdown(message.content);
|
|
103
|
+
} else if (Array.isArray(message.content)) {
|
|
104
|
+
for (const block of message.content) {
|
|
105
|
+
if (block.type === 'text') {
|
|
106
|
+
contentHtml += isStreaming ? this.escapeHtml(block.text) : renderMarkdown(block.text || '');
|
|
107
|
+
} else if (block.type === 'thinking') {
|
|
108
|
+
contentHtml += this.renderThinkingBlock(block.thinking);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Usage/cost info
|
|
114
|
+
if (message.usage && message.usage.cost) {
|
|
115
|
+
const cost = message.usage.cost.total;
|
|
116
|
+
if (cost && cost > 0) {
|
|
117
|
+
usageHtml = `<span class="message-usage">$${cost.toFixed(4)}</span>`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const streamingClass = isStreaming ? ' streaming' : '';
|
|
122
|
+
|
|
123
|
+
div.innerHTML = `
|
|
124
|
+
<div class="message-content${streamingClass}">${contentHtml}</div>
|
|
125
|
+
${usageHtml}
|
|
126
|
+
${!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>' : ''}
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
if (!isStreaming) this._setupCopyBtn(div);
|
|
130
|
+
this.container.appendChild(div);
|
|
131
|
+
if (!isHistory) this.scrollToBottom();
|
|
132
|
+
|
|
133
|
+
return div;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
renderThinkingBlock(thinking?: string) {
|
|
137
|
+
const id = 'thinking-' + Math.random().toString(36).slice(2, 8);
|
|
138
|
+
return `<div class="thinking-block">
|
|
139
|
+
<div class="thinking-toggle" onclick="var c=document.getElementById('${id}');c.classList.toggle('expanded');this.classList.toggle('expanded')">
|
|
140
|
+
<span class="chevron"><svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor"><path d="M2 1l4 3-4 3z"/></svg></span>
|
|
141
|
+
<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>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="thinking-content" id="${id}">${this.escapeHtml(thinking)}</div>
|
|
144
|
+
</div>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
updateStreamingThinking(messageElement: HTMLElement, thinking: string) {
|
|
148
|
+
let thinkingDiv = messageElement.querySelector('.streaming-thinking');
|
|
149
|
+
if (!thinkingDiv) {
|
|
150
|
+
const contentDiv = messageElement.querySelector('.message-content');
|
|
151
|
+
if (!contentDiv) return;
|
|
152
|
+
thinkingDiv = document.createElement('div');
|
|
153
|
+
thinkingDiv.className = 'thinking-block streaming-thinking';
|
|
154
|
+
thinkingDiv.innerHTML = `
|
|
155
|
+
<div class="thinking-toggle expanded" onclick="var c=this.nextElementSibling;c.classList.toggle('expanded');this.classList.toggle('expanded')">
|
|
156
|
+
<span class="chevron"><svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor"><path d="M2 1l4 3-4 3z"/></svg></span>
|
|
157
|
+
<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>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="thinking-content expanded"></div>`;
|
|
160
|
+
contentDiv.prepend(thinkingDiv);
|
|
161
|
+
}
|
|
162
|
+
const contentEl = thinkingDiv.querySelector('.thinking-content');
|
|
163
|
+
if (contentEl) {
|
|
164
|
+
contentEl.textContent = thinking;
|
|
165
|
+
this.scrollToBottom();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
updateStreamingMessage(messageElement: HTMLElement, content: string) {
|
|
170
|
+
const contentDiv = messageElement.querySelector('.message-content');
|
|
171
|
+
if (!contentDiv) return;
|
|
172
|
+
|
|
173
|
+
// Render markdown incrementally so headings, lists, inline formatting,
|
|
174
|
+
// and other block elements appear live — not just after streaming ends.
|
|
175
|
+
const rendered = renderMarkdown(content);
|
|
176
|
+
const thinkingBlock = contentDiv.querySelector('.streaming-thinking');
|
|
177
|
+
|
|
178
|
+
if (thinkingBlock) {
|
|
179
|
+
// Keep the thinking block; update only the text portion
|
|
180
|
+
let textNode = contentDiv.querySelector('.streaming-text');
|
|
181
|
+
if (!textNode) {
|
|
182
|
+
// First time text arrives after thinking started:
|
|
183
|
+
// remove any stale text nodes that were placed directly in contentDiv
|
|
184
|
+
// before the thinking block existed.
|
|
185
|
+
let node = thinkingBlock.nextSibling;
|
|
186
|
+
while (node) {
|
|
187
|
+
const next = node.nextSibling;
|
|
188
|
+
node.remove();
|
|
189
|
+
node = next;
|
|
190
|
+
}
|
|
191
|
+
textNode = document.createElement('div');
|
|
192
|
+
textNode.className = 'streaming-text';
|
|
193
|
+
contentDiv.appendChild(textNode);
|
|
194
|
+
}
|
|
195
|
+
textNode.innerHTML = rendered;
|
|
196
|
+
// Stash the raw markdown so finalizeStreamingMessage can do a clean
|
|
197
|
+
// re-render without scraping textContent from already-rendered HTML.
|
|
198
|
+
textNode.dataset.rawText = content;
|
|
199
|
+
} else {
|
|
200
|
+
contentDiv.innerHTML = rendered;
|
|
201
|
+
contentDiv.dataset.rawText = content;
|
|
202
|
+
}
|
|
203
|
+
this.scrollToBottom();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
finalizeStreamingMessage(messageElement: HTMLElement, usage: RenderMessage['usage'] | null = null, thinking = '') {
|
|
207
|
+
const contentDiv = messageElement.querySelector('.message-content');
|
|
208
|
+
if (contentDiv) {
|
|
209
|
+
contentDiv.classList.remove('streaming');
|
|
210
|
+
|
|
211
|
+
// Recover the raw markdown text we stashed during streaming updates.
|
|
212
|
+
// Falls back to textContent (which loses formatting markers) only if
|
|
213
|
+
// the dataset is somehow missing.
|
|
214
|
+
const streamingText = contentDiv.querySelector('.streaming-text');
|
|
215
|
+
const rawText =
|
|
216
|
+
(streamingText && streamingText.dataset.rawText) ||
|
|
217
|
+
contentDiv.dataset.rawText ||
|
|
218
|
+
contentDiv.textContent ||
|
|
219
|
+
'';
|
|
220
|
+
|
|
221
|
+
// Final render — catches edge cases like code blocks whose closing
|
|
222
|
+
// fence arrived on the very last delta.
|
|
223
|
+
let html = '';
|
|
224
|
+
if (thinking) {
|
|
225
|
+
html += this.renderThinkingBlock(thinking);
|
|
226
|
+
}
|
|
227
|
+
html += renderMarkdown(rawText);
|
|
228
|
+
contentDiv.innerHTML = html;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Add copy button after streaming finishes
|
|
232
|
+
if (!messageElement.querySelector('.message-copy-btn')) {
|
|
233
|
+
const btn = document.createElement('button');
|
|
234
|
+
btn.className = 'message-copy-btn';
|
|
235
|
+
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>';
|
|
236
|
+
messageElement.appendChild(btn);
|
|
237
|
+
this._setupCopyBtn(messageElement);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Add usage info if available
|
|
241
|
+
if (usage && usage.cost) {
|
|
242
|
+
const total = usage.cost.total;
|
|
243
|
+
if (total && total > 0) {
|
|
244
|
+
if (!messageElement.querySelector('.message-usage')) {
|
|
245
|
+
const span = document.createElement('span');
|
|
246
|
+
span.className = 'message-usage';
|
|
247
|
+
span.textContent = `$${total.toFixed(4)}`;
|
|
248
|
+
messageElement.appendChild(span);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
renderSystemMessage(text: string) {
|
|
255
|
+
const div = document.createElement('div');
|
|
256
|
+
div.className = 'system-message';
|
|
257
|
+
div.textContent = String(text ?? '');
|
|
258
|
+
this.container.appendChild(div);
|
|
259
|
+
this.scrollToBottom();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
renderError(errorMessage: string) {
|
|
263
|
+
const div = document.createElement('div');
|
|
264
|
+
div.className = 'error-message';
|
|
265
|
+
div.textContent = `⚠️ ${errorMessage}`;
|
|
266
|
+
this.container.appendChild(div);
|
|
267
|
+
this.scrollToBottom();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_setupCopyBtn(messageEl: HTMLElement) {
|
|
271
|
+
const btn = messageEl.querySelector('.message-copy-btn');
|
|
272
|
+
if (!btn) return;
|
|
273
|
+
btn.addEventListener('click', () => {
|
|
274
|
+
const content = messageEl.querySelector('.message-content');
|
|
275
|
+
if (!content) return;
|
|
276
|
+
const text = content.textContent;
|
|
277
|
+
// Fallback for non-HTTPS (LAN access)
|
|
278
|
+
const copyText = (t: string) => {
|
|
279
|
+
if (navigator.clipboard) return navigator.clipboard.writeText(t);
|
|
280
|
+
const ta = document.createElement('textarea');
|
|
281
|
+
ta.value = t;
|
|
282
|
+
ta.style.cssText = 'position:fixed;left:-9999px';
|
|
283
|
+
document.body.appendChild(ta);
|
|
284
|
+
ta.select();
|
|
285
|
+
document.execCommand('copy');
|
|
286
|
+
document.body.removeChild(ta);
|
|
287
|
+
return Promise.resolve();
|
|
288
|
+
};
|
|
289
|
+
copyText(text).then(() => {
|
|
290
|
+
btn.classList.add('copied');
|
|
291
|
+
setTimeout(() => {
|
|
292
|
+
btn.classList.remove('copied');
|
|
293
|
+
}, 1500);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
escapeHtml(text: unknown) {
|
|
299
|
+
const div = document.createElement('div');
|
|
300
|
+
div.textContent = String(text ?? '');
|
|
301
|
+
return div.innerHTML;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
scrollToBottom() {
|
|
305
|
+
if (this.isNearBottom) {
|
|
306
|
+
requestAnimationFrame(() => {
|
|
307
|
+
this.container.scrollTop = this.container.scrollHeight;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|