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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launcher — project directory picker with visual bubbles
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
type LauncherProject = {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
sessionCount?: number;
|
|
9
|
+
lastActive?: number | null;
|
|
10
|
+
active?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class Launcher {
|
|
14
|
+
container: HTMLElement;
|
|
15
|
+
onLaunch: (projectPath: string) => void | Promise<void>;
|
|
16
|
+
projects: LauncherProject[];
|
|
17
|
+
|
|
18
|
+
constructor(container: HTMLElement, onLaunch: (projectPath: string) => void | Promise<void>) {
|
|
19
|
+
this.container = container;
|
|
20
|
+
this.onLaunch = onLaunch;
|
|
21
|
+
this.projects = [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async load() {
|
|
25
|
+
this.container.innerHTML = '<div class="launcher-loading">Loading projects…</div>';
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch('/api/projects');
|
|
28
|
+
const data = await res.json();
|
|
29
|
+
this.projects = data.projects || [];
|
|
30
|
+
this.render();
|
|
31
|
+
} catch (e) {
|
|
32
|
+
this.container.innerHTML = '<div class="launcher-loading">Failed to load projects</div>';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
render() {
|
|
37
|
+
if (!this.projects.length) {
|
|
38
|
+
this.container.innerHTML = `
|
|
39
|
+
<div class="launcher-empty">
|
|
40
|
+
<p>No projects directory configured.</p>
|
|
41
|
+
<p class="hint">Add <code>"tau": { "projectsDir": "~/Projects" }</code> to <code>~/.pi/agent/settings.json</code></p>
|
|
42
|
+
</div>`;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Find max session count for relative sizing
|
|
47
|
+
const maxSessions = Math.max(1, ...this.projects.map(p => p.sessionCount || 0));
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
|
|
50
|
+
// Sort: active first, then by recency
|
|
51
|
+
const sorted = [...this.projects].sort((a, b) => {
|
|
52
|
+
if (a.active && !b.active) return -1;
|
|
53
|
+
if (!a.active && b.active) return 1;
|
|
54
|
+
return (b.lastActive || 0) - (a.lastActive || 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const bubbles = sorted.map(p => {
|
|
58
|
+
// Size: scale between 0.7 and 1.3 based on session count
|
|
59
|
+
const sizeRatio = 0.7 + ((p.sessionCount || 0) / maxSessions) * 0.6;
|
|
60
|
+
|
|
61
|
+
// Recency: how fresh is this project (0 = ancient, 1 = today)
|
|
62
|
+
let freshness = 0;
|
|
63
|
+
if (p.lastActive) {
|
|
64
|
+
const ageMs = now - p.lastActive;
|
|
65
|
+
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
66
|
+
freshness = Math.max(0, 1 - (ageDays / 30)); // fades over 30 days
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return `
|
|
70
|
+
<button class="launcher-bubble${p.active ? ' active' : ''}"
|
|
71
|
+
data-path="${this.escAttr(p.path)}"
|
|
72
|
+
style="--size: ${sizeRatio}; --freshness: ${freshness.toFixed(2)}"
|
|
73
|
+
title="${this.escAttr(p.path)}${p.active ? ' (running)' : ''}${p.sessionCount ? ` • ${p.sessionCount} session${p.sessionCount !== 1 ? 's' : ''}` : ''}">
|
|
74
|
+
<span class="launcher-bubble-name">${this.escHtml(p.name)}</span>
|
|
75
|
+
${p.active ? '<span class="launcher-bubble-dot"></span>' : ''}
|
|
76
|
+
</button>`;
|
|
77
|
+
}).join('');
|
|
78
|
+
|
|
79
|
+
this.container.innerHTML = `
|
|
80
|
+
<div class="launcher-content">
|
|
81
|
+
<div class="launcher-title">Projects</div>
|
|
82
|
+
<div class="launcher-grid">${bubbles}</div>
|
|
83
|
+
</div>`;
|
|
84
|
+
|
|
85
|
+
// Bind click handlers
|
|
86
|
+
this.container.querySelectorAll('.launcher-bubble').forEach(btn => {
|
|
87
|
+
btn.addEventListener('click', () => {
|
|
88
|
+
const projectPath = btn.dataset.path;
|
|
89
|
+
if (projectPath) this.onLaunch(projectPath);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
escHtml(s: string) {
|
|
95
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
escAttr(s: string) {
|
|
99
|
+
return s.replace(/&/g, '&').replace(/"/g, '"');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
type SpeechRecognitionConstructor = new () => EventTarget & {
|
|
2
|
+
continuous: boolean;
|
|
3
|
+
interimResults: boolean;
|
|
4
|
+
lang: string;
|
|
5
|
+
start(): void;
|
|
6
|
+
stop(): void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
interface Window {
|
|
10
|
+
copyCode?: (button: HTMLElement) => void;
|
|
11
|
+
SpeechRecognition?: SpeechRecognitionConstructor;
|
|
12
|
+
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Element {
|
|
16
|
+
dataset: DOMStringMap;
|
|
17
|
+
disabled: boolean;
|
|
18
|
+
files: FileList | null;
|
|
19
|
+
focus(options?: FocusOptions): void;
|
|
20
|
+
isContentEditable: boolean;
|
|
21
|
+
onclick: ((this: GlobalEventHandlers, ev: MouseEvent) => unknown) | null;
|
|
22
|
+
placeholder: string;
|
|
23
|
+
select(): void;
|
|
24
|
+
selectionEnd: number | null;
|
|
25
|
+
selectionStart: number | null;
|
|
26
|
+
setSelectionRange(start: number, end: number, direction?: 'forward' | 'backward' | 'none'): void;
|
|
27
|
+
style: CSSStyleDeclaration;
|
|
28
|
+
value: string;
|
|
29
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
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
|
+
|
|
8
|
+
export function renderMarkdown(text: string) {
|
|
9
|
+
if (!text) return '';
|
|
10
|
+
|
|
11
|
+
// Normalize line endings
|
|
12
|
+
text = text.replace(/\r\n/g, '\n');
|
|
13
|
+
|
|
14
|
+
// Extract code blocks first to protect them
|
|
15
|
+
const codeBlocks: { lang: string; code: string }[] = [];
|
|
16
|
+
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang: string, code: string) => {
|
|
17
|
+
const idx = codeBlocks.length;
|
|
18
|
+
codeBlocks.push({ lang, code: code.replace(/\n$/, '') });
|
|
19
|
+
return `%%CODEBLOCK_${idx}%%`;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Extract display math after code fences (so $$ inside fences stays literal)
|
|
23
|
+
// but before the line split, since $$...$$ may span multiple lines.
|
|
24
|
+
const mathBlocks: string[] = [];
|
|
25
|
+
text = extractDisplayMath(text, mathBlocks);
|
|
26
|
+
|
|
27
|
+
// Split into lines and process block-level elements
|
|
28
|
+
const lines = text.split('\n');
|
|
29
|
+
let html = '';
|
|
30
|
+
let inList = false;
|
|
31
|
+
let listType = '';
|
|
32
|
+
let inBlockquote = false;
|
|
33
|
+
let blockquoteLines: string[] = [];
|
|
34
|
+
|
|
35
|
+
function flushBlockquote() {
|
|
36
|
+
if (inBlockquote) {
|
|
37
|
+
html += '<blockquote>' + blockquoteLines.map(l => renderInline(l)).join('<br>') + '</blockquote>';
|
|
38
|
+
inBlockquote = false;
|
|
39
|
+
blockquoteLines = [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function flushList() {
|
|
44
|
+
if (inList) { html += `</${listType}>`; inList = false; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check if a line is a table separator (e.g. |---|---|)
|
|
48
|
+
function isTableSeparator(line: string) {
|
|
49
|
+
return /^\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check if a line looks like a table row
|
|
53
|
+
function isTableRow(line: string) {
|
|
54
|
+
return line.trim().startsWith('|') && line.trim().endsWith('|');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse alignment from separator row
|
|
58
|
+
function parseAlignments(line: string) {
|
|
59
|
+
return line.split('|').filter((c: string) => c.trim()).map((cell: string) => {
|
|
60
|
+
const trimmed = cell.trim();
|
|
61
|
+
if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
|
|
62
|
+
if (trimmed.endsWith(':')) return 'right';
|
|
63
|
+
return 'left';
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
let line = lines[i];
|
|
69
|
+
|
|
70
|
+
// Code block placeholder
|
|
71
|
+
const codeMatch = line.match(/^%%CODEBLOCK_(\d+)%%$/);
|
|
72
|
+
if (codeMatch) {
|
|
73
|
+
flushList();
|
|
74
|
+
flushBlockquote();
|
|
75
|
+
const block = codeBlocks[parseInt(codeMatch[1])];
|
|
76
|
+
const langLabel = block.lang || 'code';
|
|
77
|
+
html += `<div class="code-block-wrapper">`;
|
|
78
|
+
html += `<div class="code-block-header"><span>${escapeHtml(langLabel)}</span><button class="copy-btn" onclick="copyCode(this)">Copy</button></div>`;
|
|
79
|
+
html += `<pre><code>${escapeHtml(block.code)}</code></pre></div>`;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Display math placeholder
|
|
84
|
+
const mathMatch = line.match(/^%%MATHBLOCK(\d+)%%$/);
|
|
85
|
+
if (mathMatch) {
|
|
86
|
+
flushList();
|
|
87
|
+
flushBlockquote();
|
|
88
|
+
html += `<div class="math-block">${mathBlocks[parseInt(mathMatch[1])]}</div>`;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Table detection: look ahead for header + separator pattern
|
|
93
|
+
if (isTableRow(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
|
94
|
+
flushList();
|
|
95
|
+
flushBlockquote();
|
|
96
|
+
|
|
97
|
+
const alignments = parseAlignments(lines[i + 1]);
|
|
98
|
+
|
|
99
|
+
// Parse header
|
|
100
|
+
const headerCells = line.split('|').filter((c: string) => c.trim() !== '' || line.trim() === '|');
|
|
101
|
+
// More robust: split between first and last pipe
|
|
102
|
+
const headerRow = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
103
|
+
|
|
104
|
+
html += '<div class="table-wrapper"><table><thead><tr>';
|
|
105
|
+
headerRow.forEach((cell: string, idx: number) => {
|
|
106
|
+
const align = alignments[idx] || 'left';
|
|
107
|
+
html += `<th style="text-align:${align}">${renderInline(cell.trim())}</th>`;
|
|
108
|
+
});
|
|
109
|
+
html += '</tr></thead><tbody>';
|
|
110
|
+
|
|
111
|
+
// Skip separator
|
|
112
|
+
i += 2;
|
|
113
|
+
|
|
114
|
+
// Parse body rows
|
|
115
|
+
while (i < lines.length && isTableRow(lines[i])) {
|
|
116
|
+
const rowCells = lines[i].trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
117
|
+
html += '<tr>';
|
|
118
|
+
rowCells.forEach((cell: string, idx: number) => {
|
|
119
|
+
const align = alignments[idx] || 'left';
|
|
120
|
+
html += `<td style="text-align:${align}">${renderInline(cell.trim())}</td>`;
|
|
121
|
+
});
|
|
122
|
+
html += '</tr>';
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
html += '</tbody></table></div>';
|
|
127
|
+
i--; // back up since the for loop will increment
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Horizontal rule
|
|
132
|
+
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
133
|
+
flushList();
|
|
134
|
+
flushBlockquote();
|
|
135
|
+
html += '<hr>';
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Headings
|
|
140
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
141
|
+
if (headingMatch) {
|
|
142
|
+
flushList();
|
|
143
|
+
flushBlockquote();
|
|
144
|
+
const level = headingMatch[1].length;
|
|
145
|
+
html += `<h${level}>${renderInline(headingMatch[2])}</h${level}>`;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Blockquote — handle `>` with or without trailing space, and empty `>` lines
|
|
150
|
+
if (/^>\s?/.test(line)) {
|
|
151
|
+
flushList();
|
|
152
|
+
if (!inBlockquote) { inBlockquote = true; blockquoteLines = []; }
|
|
153
|
+
const content = line.replace(/^>\s?/, '');
|
|
154
|
+
if (content === '') {
|
|
155
|
+
// Empty blockquote line acts as paragraph break within quote
|
|
156
|
+
blockquoteLines.push('');
|
|
157
|
+
} else {
|
|
158
|
+
blockquoteLines.push(content);
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
} else if (inBlockquote) {
|
|
162
|
+
flushBlockquote();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Task list (must check before regular list)
|
|
166
|
+
const taskMatch = line.match(/^(\s*)[*\-+]\s+\[([ xX])\]\s+(.+)$/);
|
|
167
|
+
if (taskMatch) {
|
|
168
|
+
if (!inList || listType !== 'ul') {
|
|
169
|
+
flushList();
|
|
170
|
+
html += '<ul class="task-list">';
|
|
171
|
+
inList = true;
|
|
172
|
+
listType = 'ul';
|
|
173
|
+
}
|
|
174
|
+
const checked = taskMatch[2] !== ' ';
|
|
175
|
+
html += `<li class="task-list-item"><input type="checkbox" disabled ${checked ? 'checked' : ''}> ${renderInline(taskMatch[3])}</li>`;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Unordered list
|
|
180
|
+
const ulMatch = line.match(/^(\s*)[*\-+]\s+(.+)$/);
|
|
181
|
+
if (ulMatch) {
|
|
182
|
+
flushBlockquote();
|
|
183
|
+
if (!inList || listType !== 'ul') {
|
|
184
|
+
if (inList) html += `</${listType}>`;
|
|
185
|
+
html += '<ul>';
|
|
186
|
+
inList = true;
|
|
187
|
+
listType = 'ul';
|
|
188
|
+
}
|
|
189
|
+
html += `<li>${renderInline(ulMatch[2])}</li>`;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Ordered list
|
|
194
|
+
const olMatch = line.match(/^(\s*)\d+\.\s+(.+)$/);
|
|
195
|
+
if (olMatch) {
|
|
196
|
+
flushBlockquote();
|
|
197
|
+
if (!inList || listType !== 'ol') {
|
|
198
|
+
if (inList) html += `</${listType}>`;
|
|
199
|
+
html += '<ol>';
|
|
200
|
+
inList = true;
|
|
201
|
+
listType = 'ol';
|
|
202
|
+
}
|
|
203
|
+
html += `<li>${renderInline(olMatch[2])}</li>`;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Close list if we're out of list items
|
|
208
|
+
flushList();
|
|
209
|
+
|
|
210
|
+
// Empty line
|
|
211
|
+
if (line.trim() === '') {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Regular paragraph
|
|
216
|
+
html += `<p>${renderInline(line)}</p>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Close any open blocks
|
|
220
|
+
flushList();
|
|
221
|
+
flushBlockquote();
|
|
222
|
+
|
|
223
|
+
return html;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Lightweight user-message renderer — inline formatting + blockquotes only.
|
|
228
|
+
* Preserves whitespace/newlines for everything else.
|
|
229
|
+
*/
|
|
230
|
+
export function renderUserMarkdown(text: string) {
|
|
231
|
+
if (!text) return '';
|
|
232
|
+
text = text.replace(/\r\n/g, '\n');
|
|
233
|
+
|
|
234
|
+
const mathBlocks: string[] = [];
|
|
235
|
+
text = extractDisplayMath(text, mathBlocks);
|
|
236
|
+
|
|
237
|
+
const lines = text.split('\n');
|
|
238
|
+
let html = '';
|
|
239
|
+
let inBlockquote = false;
|
|
240
|
+
let bqLines: string[] = [];
|
|
241
|
+
|
|
242
|
+
function flushBq() {
|
|
243
|
+
if (inBlockquote) {
|
|
244
|
+
html += '<blockquote>' + bqLines.map(l => renderInline(l)).join('<br>') + '</blockquote>';
|
|
245
|
+
inBlockquote = false;
|
|
246
|
+
bqLines = [];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const line of lines) {
|
|
251
|
+
const mathMatch = line.match(/^%%MATHBLOCK(\d+)%%$/);
|
|
252
|
+
if (mathMatch) {
|
|
253
|
+
flushBq();
|
|
254
|
+
html += `<div class="math-block">${mathBlocks[parseInt(mathMatch[1])]}</div>`;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (/^>\s?/.test(line)) {
|
|
258
|
+
if (!inBlockquote) { inBlockquote = true; bqLines = []; }
|
|
259
|
+
bqLines.push(line.replace(/^>\s?/, ''));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
flushBq();
|
|
263
|
+
html += renderInline(line) + '\n';
|
|
264
|
+
}
|
|
265
|
+
flushBq();
|
|
266
|
+
|
|
267
|
+
return html.replace(/\n$/, '');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renderInline(text: string) {
|
|
271
|
+
// Inline code (must come first to protect content)
|
|
272
|
+
const codeSpans: string[] = [];
|
|
273
|
+
text = text.replace(/`([^`]+)`/g, (_, code: string) => {
|
|
274
|
+
const idx = codeSpans.length;
|
|
275
|
+
codeSpans.push(`<code>${escapeHtml(code)}</code>`);
|
|
276
|
+
return `%%ICODE${idx}%%`;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Inline math — after inline code (so `$x$` stays code) but before the
|
|
280
|
+
// emphasis regexes, which would otherwise mangle math like $a_i * b_i$.
|
|
281
|
+
const mathSpans: string[] = [];
|
|
282
|
+
const putMath = (src: string) => `%%IMATH${mathSpans.push(renderMath(src.trim(), false)) - 1}%%`;
|
|
283
|
+
text = text.replace(/\\\((.+?)\\\)/g, (_, src: string) => putMath(src));
|
|
284
|
+
// Single-$ form with currency heuristics: opening $ not escaped/doubled and
|
|
285
|
+
// not followed by whitespace; closing $ not escaped, not preceded by
|
|
286
|
+
// whitespace, not followed by a digit ("$5 and $10" stays text).
|
|
287
|
+
text = text.replace(/(?<![\\$])\$(?!\s)((?:\\.|[^$])+?)(?<![\s\\])\$(?!\d)/g, (_, src: string) => putMath(src));
|
|
288
|
+
|
|
289
|
+
// Images (before links so  isn't caught by link regex)
|
|
290
|
+
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="inline-image">');
|
|
291
|
+
|
|
292
|
+
// Bold + italic
|
|
293
|
+
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
294
|
+
|
|
295
|
+
// Bold
|
|
296
|
+
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
297
|
+
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
|
298
|
+
|
|
299
|
+
// Italic
|
|
300
|
+
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
301
|
+
text = text.replace(/_(.+?)_/g, '<em>$1</em>');
|
|
302
|
+
|
|
303
|
+
// Strikethrough
|
|
304
|
+
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
|
305
|
+
|
|
306
|
+
// Links
|
|
307
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
308
|
+
|
|
309
|
+
// Auto-link bare URLs
|
|
310
|
+
text = text.replace(/(^|[^"'])(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
|
|
311
|
+
|
|
312
|
+
// Restore inline math and inline code last so the regexes above never see
|
|
313
|
+
// the generated HTML.
|
|
314
|
+
text = text.replace(/%%IMATH(\d+)%%/g, (_, idx: string) => mathSpans[parseInt(idx)]);
|
|
315
|
+
text = text.replace(/%%ICODE(\d+)%%/g, (_, idx: string) => codeSpans[parseInt(idx)]);
|
|
316
|
+
|
|
317
|
+
return text;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
type Katex = {
|
|
321
|
+
renderToString(tex: string, opts: { displayMode: boolean; throwOnError: boolean }): string;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
function renderMath(src: string, displayMode: boolean) {
|
|
325
|
+
const katex = (globalThis as { katex?: Katex }).katex;
|
|
326
|
+
if (!katex) return `<code class="math-fallback">${escapeHtml(src)}</code>`;
|
|
327
|
+
try {
|
|
328
|
+
return katex.renderToString(src, { displayMode, throwOnError: false });
|
|
329
|
+
} catch {
|
|
330
|
+
return `<code class="math-fallback">${escapeHtml(src)}</code>`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Replaces $$...$$ and \[...\] segments with %%MATHBLOCK<N>%% placeholders on
|
|
335
|
+
// their own lines, pushing the KaTeX-rendered HTML into `blocks`. The
|
|
336
|
+
// placeholder deliberately has no underscore (matching %%ICODE0%%) so the
|
|
337
|
+
// italic regex can never split it.
|
|
338
|
+
function extractDisplayMath(text: string, blocks: string[]) {
|
|
339
|
+
const put = (_: string, src: string) =>
|
|
340
|
+
`\n%%MATHBLOCK${blocks.push(renderMath(src.trim(), true)) - 1}%%\n`;
|
|
341
|
+
text = text.replace(/\$\$([\s\S]+?)\$\$/g, put);
|
|
342
|
+
text = text.replace(/\\\[([\s\S]+?)\\\]/g, put);
|
|
343
|
+
return text;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function escapeHtml(text: string) {
|
|
347
|
+
return text
|
|
348
|
+
.replace(/&/g, '&')
|
|
349
|
+
.replace(/</g, '<')
|
|
350
|
+
.replace(/>/g, '>')
|
|
351
|
+
.replace(/"/g, '"');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Global copy function for code blocks. Guarded so the module can be imported
|
|
355
|
+
// under node --test, where there is no window.
|
|
356
|
+
if (typeof window !== 'undefined') {
|
|
357
|
+
window.copyCode = function(btn) {
|
|
358
|
+
const wrapper = btn.closest('.code-block-wrapper');
|
|
359
|
+
if (!wrapper) return;
|
|
360
|
+
const codeBlock = wrapper.querySelector('code');
|
|
361
|
+
if (!codeBlock) return;
|
|
362
|
+
const text = codeBlock.textContent || '';
|
|
363
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
364
|
+
btn.textContent = 'Copied!';
|
|
365
|
+
btn.classList.add('copied');
|
|
366
|
+
setTimeout(() => {
|
|
367
|
+
btn.textContent = 'Copy';
|
|
368
|
+
btn.classList.remove('copied');
|
|
369
|
+
}, 2000);
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
}
|