@liguoshuai/pi-web-chat 2.14.12 → 2.16.5
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 +1 -3
- package/docs/ARCHITECTURE.md +3 -6
- package/package.json +4 -4
- package/public/app.js +153 -267
- package/public/index.html +9 -0
- package/public/markdown.js +278 -0
- package/public/style.css +9 -0
- package/docs/CHANGELOG.md +0 -990
- package/docs/ISSUES.md +0 -268
package/public/index.html
CHANGED
|
@@ -76,6 +76,14 @@
|
|
|
76
76
|
</svg>
|
|
77
77
|
<span class="topbar-action-label">导出</span>
|
|
78
78
|
</button>
|
|
79
|
+
<button class="btn-topbar-action" id="btnCompactChat" title="压缩当前上下文(长会话变慢时使用)">
|
|
80
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
81
|
+
<polyline points="4 7 4 4 20 4 20 7"></polyline>
|
|
82
|
+
<line x1="9" y1="20" x2="15" y2="20"></line>
|
|
83
|
+
<line x1="12" y1="4" x2="12" y2="20"></line>
|
|
84
|
+
</svg>
|
|
85
|
+
<span class="topbar-action-label">压缩</span>
|
|
86
|
+
</button>
|
|
79
87
|
<div class="model-menu" id="modelMenu"></div>
|
|
80
88
|
<div class="thinking-menu" id="thinkingMenu"></div>
|
|
81
89
|
</div>
|
|
@@ -167,6 +175,7 @@
|
|
|
167
175
|
|
|
168
176
|
<!-- Toast notification -->
|
|
169
177
|
<div class="toast" id="toast"></div>
|
|
178
|
+
<script src="/markdown.js"></script>
|
|
170
179
|
<script src="/app.js"></script>
|
|
171
180
|
</body>
|
|
172
181
|
</html>
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// markdown.js — Lightweight, safe Markdown renderer for pi-web-chat
|
|
2
|
+
// Handles code fence blocks, tables, lists, task checkboxes, headers, blockquotes, inline formatting, and safe URLs.
|
|
3
|
+
|
|
4
|
+
(function (global) {
|
|
5
|
+
function escapeHtml(s) {
|
|
6
|
+
if (!s) return "";
|
|
7
|
+
return s.replace(/&/g, "&")
|
|
8
|
+
.replace(/</g, "<")
|
|
9
|
+
.replace(/>/g, ">")
|
|
10
|
+
.replace(/"/g, """)
|
|
11
|
+
.replace(/'/g, "'");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function copyToClipboard(text) {
|
|
15
|
+
if (!text) return false;
|
|
16
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
17
|
+
try {
|
|
18
|
+
await navigator.clipboard.writeText(text);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
console.warn("navigator.clipboard.writeText failed:", e);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const ta = document.createElement("textarea");
|
|
26
|
+
ta.value = text;
|
|
27
|
+
ta.style.position = "fixed";
|
|
28
|
+
ta.style.left = "-9999px";
|
|
29
|
+
ta.style.top = "-9999px";
|
|
30
|
+
document.body.appendChild(ta);
|
|
31
|
+
ta.focus();
|
|
32
|
+
ta.select();
|
|
33
|
+
const ok = document.execCommand("copy");
|
|
34
|
+
document.body.removeChild(ta);
|
|
35
|
+
return ok;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error("Fallback copy error:", e);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizeUrl(url) {
|
|
43
|
+
if (!url) return "#";
|
|
44
|
+
const trimmed = url.trim();
|
|
45
|
+
if (/^(https?:\/\/|mailto:|#)/i.test(trimmed)) {
|
|
46
|
+
return trimmed.replace(/"/g, """).replace(/'/g, "'");
|
|
47
|
+
}
|
|
48
|
+
return "#";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function mdInlineBlock(text) {
|
|
52
|
+
let s = escapeHtml(text);
|
|
53
|
+
// bold
|
|
54
|
+
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
55
|
+
s = s.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
|
56
|
+
// strikethrough
|
|
57
|
+
s = s.replace(/~~(.+?)~~/g, "<del>$1</del>");
|
|
58
|
+
// italic (asterisk)
|
|
59
|
+
s = s.replace(/(^|[^*])\*([^*\s](?:[^*]*?[^*\s])?)\*(?!\*)/g, "$1<em>$2</em>");
|
|
60
|
+
// italic (underscore): only match boundary/space so identifier_names are preserved
|
|
61
|
+
s = s.replace(/(^|[\s(\[<,;:])_([^_\s](?:[^_]*?[^_\s])?)_(?=[\s)\]>,;:!?.]|$)/g, "$1<em>$2</em>");
|
|
62
|
+
// links [txt](url) - strictly sanitize URL
|
|
63
|
+
s = s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)/g, (match, txt, url) => {
|
|
64
|
+
const safeUrl = sanitizeUrl(url);
|
|
65
|
+
return `<a href="${safeUrl}" target="_blank" rel="noopener">${txt}</a>`;
|
|
66
|
+
});
|
|
67
|
+
return s;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mdTable(lines) {
|
|
71
|
+
const parseRow = (l) => l.split("|").map(c => c.trim()).filter((_, i, arr) => !(i === 0 && arr[0] === "") && !(i === arr.length - 1 && arr[arr.length - 1] === ""));
|
|
72
|
+
const header = parseRow(lines[0]);
|
|
73
|
+
const body = lines.slice(2).filter(l => l.trim()).map(parseRow);
|
|
74
|
+
let h = '<table><thead><tr>';
|
|
75
|
+
header.forEach((c) => h += `<th>${mdInlineBlock(c)}</th>`);
|
|
76
|
+
h += '</tr></thead><tbody>';
|
|
77
|
+
body.forEach((r) => {
|
|
78
|
+
h += '<tr>';
|
|
79
|
+
r.forEach((c) => h += `<td>${mdInlineBlock(c)}</td>`);
|
|
80
|
+
h += '</tr>';
|
|
81
|
+
});
|
|
82
|
+
h += '</tbody></table>';
|
|
83
|
+
return h;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderInlineMd(text) {
|
|
87
|
+
// tables, then markdown-ish transforms. Escape first.
|
|
88
|
+
// Split out inline code first using placeholders to protect them.
|
|
89
|
+
const codeChunks = [];
|
|
90
|
+
let t = text.replace(/`([^`\n]+)`/g, (m) => {
|
|
91
|
+
const i = codeChunks.length;
|
|
92
|
+
codeChunks.push(m);
|
|
93
|
+
return `\u0000CODE${i}\u0000`;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const lines = t.split("\n");
|
|
97
|
+
const out = [];
|
|
98
|
+
let i = 0;
|
|
99
|
+
while (i < lines.length) {
|
|
100
|
+
if (lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?[\s\-:|]+\|?\s*$/.test(lines[i + 1]) && lines[i+1].includes("-")) {
|
|
101
|
+
const header = lines[i];
|
|
102
|
+
const tblLines = [header, lines[i + 1]];
|
|
103
|
+
let j = i + 2;
|
|
104
|
+
while (j < lines.length && lines[j].includes("|")) { tblLines.push(lines[j]); j++; }
|
|
105
|
+
out.push({ kind: "table", lines: tblLines });
|
|
106
|
+
i = j;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
out.push({ kind: "line", text: lines[i] });
|
|
110
|
+
i++;
|
|
111
|
+
}
|
|
112
|
+
let outHtml = "";
|
|
113
|
+
let para = [];
|
|
114
|
+
let quoteLines = [];
|
|
115
|
+
let linkListOpen = null;
|
|
116
|
+
let linkListOrdered = null;
|
|
117
|
+
|
|
118
|
+
function flushList() {
|
|
119
|
+
if (linkListOpen) {
|
|
120
|
+
outHtml += linkListOpen === "ol" ? "</ol>" : "</ul>";
|
|
121
|
+
linkListOpen = null;
|
|
122
|
+
linkListOrdered = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function flushQuote() {
|
|
127
|
+
if (quoteLines.length === 0) return;
|
|
128
|
+
const block = quoteLines.map(l => mdInlineBlock(l)).join("<br>");
|
|
129
|
+
quoteLines = [];
|
|
130
|
+
outHtml += `<blockquote>${block}</blockquote>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function flushPara() {
|
|
134
|
+
if (para.length === 0) return;
|
|
135
|
+
const block = para.join("\n").trim();
|
|
136
|
+
para = [];
|
|
137
|
+
outHtml += "<p>" + mdInlineBlock(block).replace(/\n/g, "<br>") + "</p>";
|
|
138
|
+
}
|
|
139
|
+
for (const seg of out) {
|
|
140
|
+
if (seg.kind === "table") {
|
|
141
|
+
flushPara();
|
|
142
|
+
flushList();
|
|
143
|
+
flushQuote();
|
|
144
|
+
outHtml += mdTable(seg.lines);
|
|
145
|
+
} else if (seg.kind === "line") {
|
|
146
|
+
// horizontal rule
|
|
147
|
+
if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
|
|
148
|
+
flushPara();
|
|
149
|
+
flushList();
|
|
150
|
+
flushQuote();
|
|
151
|
+
outHtml += "<hr>";
|
|
152
|
+
} else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
|
|
153
|
+
// headings
|
|
154
|
+
const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
|
|
155
|
+
flushPara();
|
|
156
|
+
flushList();
|
|
157
|
+
flushQuote();
|
|
158
|
+
const level = m[1].length;
|
|
159
|
+
outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
|
|
160
|
+
} else if (/^\s*$/.test(seg.text)) {
|
|
161
|
+
flushPara();
|
|
162
|
+
flushList();
|
|
163
|
+
flushQuote();
|
|
164
|
+
} else if (/^>\s?/.test(seg.text)) {
|
|
165
|
+
// blockquote line (accumulate adjacent lines into a single blockquote)
|
|
166
|
+
flushPara();
|
|
167
|
+
flushList();
|
|
168
|
+
quoteLines.push(seg.text.replace(/^>\s?/, ""));
|
|
169
|
+
} else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
|
|
170
|
+
// list item
|
|
171
|
+
flushQuote();
|
|
172
|
+
const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
|
|
173
|
+
if (!linkListOpen || linkListOrdered !== isOrdered) {
|
|
174
|
+
flushPara();
|
|
175
|
+
flushList();
|
|
176
|
+
linkListOpen = isOrdered ? "ol" : "ul";
|
|
177
|
+
linkListOrdered = isOrdered;
|
|
178
|
+
outHtml += "<" + linkListOpen + ">";
|
|
179
|
+
}
|
|
180
|
+
let itemText = seg.text.replace(/^\s*([-*+]|\d+\.)\s+/, "");
|
|
181
|
+
let taskPrefix = "";
|
|
182
|
+
if (/^\[ \]\s+/.test(itemText)) {
|
|
183
|
+
taskPrefix = '<input type="checkbox" disabled class="task-list-item-checkbox"> ';
|
|
184
|
+
itemText = itemText.replace(/^\[ \]\s+/, "");
|
|
185
|
+
} else if (/^\[[xX]\]\s+/.test(itemText)) {
|
|
186
|
+
taskPrefix = '<input type="checkbox" checked disabled class="task-list-item-checkbox"> ';
|
|
187
|
+
itemText = itemText.replace(/^\[[xX]\]\s+/, "");
|
|
188
|
+
}
|
|
189
|
+
outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
|
|
190
|
+
} else {
|
|
191
|
+
flushList();
|
|
192
|
+
flushQuote();
|
|
193
|
+
para.push(seg.text);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
flushList();
|
|
198
|
+
flushQuote();
|
|
199
|
+
flushPara();
|
|
200
|
+
// restore inline code
|
|
201
|
+
outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => {
|
|
202
|
+
const chunk = codeChunks[+n];
|
|
203
|
+
return chunk ? `<code>${escapeHtml(chunk.slice(1, -1))}</code>` : "";
|
|
204
|
+
});
|
|
205
|
+
return outHtml;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderMarkdown(md) {
|
|
209
|
+
if (!md) return "";
|
|
210
|
+
if (typeof md !== "string") md = String(md);
|
|
211
|
+
const parts = [];
|
|
212
|
+
let rest = md;
|
|
213
|
+
while (rest.length) {
|
|
214
|
+
const fenceMatch = rest.match(/(?:^|\n)```/);
|
|
215
|
+
if (!fenceMatch) {
|
|
216
|
+
parts.push({ kind: "md", text: rest });
|
|
217
|
+
rest = "";
|
|
218
|
+
} else {
|
|
219
|
+
const fenceIdx = fenceMatch.index + (fenceMatch[0].length - 3);
|
|
220
|
+
if (fenceIdx > 0) parts.push({ kind: "md", text: rest.slice(0, fenceIdx) });
|
|
221
|
+
rest = rest.slice(fenceIdx + 3);
|
|
222
|
+
const nl = rest.indexOf("\n");
|
|
223
|
+
let lang = "";
|
|
224
|
+
if (nl !== -1) {
|
|
225
|
+
const firstLine = rest.slice(0, nl).trim();
|
|
226
|
+
if (firstLine && !firstLine.includes("```")) lang = firstLine;
|
|
227
|
+
rest = rest.slice(nl + 1);
|
|
228
|
+
}
|
|
229
|
+
const closeIdx = rest.indexOf("```");
|
|
230
|
+
let code;
|
|
231
|
+
if (closeIdx === -1) { code = rest; rest = ""; }
|
|
232
|
+
else { code = rest.slice(0, closeIdx); rest = rest.slice(closeIdx + 3).replace(/^\n/, ""); }
|
|
233
|
+
parts.push({ kind: "code", lang, code });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
let html = "";
|
|
237
|
+
for (const p of parts) {
|
|
238
|
+
if (p.kind === "code") {
|
|
239
|
+
const lang = p.lang || "";
|
|
240
|
+
const displayLang = lang || "code";
|
|
241
|
+
html += `<div class="code-block-wrapper">` +
|
|
242
|
+
`<div class="code-block-header">` +
|
|
243
|
+
`<span class="code-block-lang">${escapeHtml(displayLang)}</span>` +
|
|
244
|
+
`<button class="btn-copy-code" type="button" aria-label="复制代码" title="复制代码">` +
|
|
245
|
+
`<svg width="13" height="13" 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" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>` +
|
|
246
|
+
`<span>复制</span>` +
|
|
247
|
+
`</button>` +
|
|
248
|
+
`</div>` +
|
|
249
|
+
`<pre><code data-lang="${escapeHtml(lang)}">${escapeHtml(p.code)}</code></pre>` +
|
|
250
|
+
`</div>`;
|
|
251
|
+
} else {
|
|
252
|
+
html += renderInlineMd(p.text);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return html;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Export to global scope & Node/module exports
|
|
259
|
+
const PiMarkdown = {
|
|
260
|
+
escapeHtml,
|
|
261
|
+
copyToClipboard,
|
|
262
|
+
sanitizeUrl,
|
|
263
|
+
mdInlineBlock,
|
|
264
|
+
mdTable,
|
|
265
|
+
renderInlineMd,
|
|
266
|
+
renderMarkdown
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
if (typeof exports === "object" && typeof module !== "undefined") {
|
|
270
|
+
module.exports = PiMarkdown;
|
|
271
|
+
}
|
|
272
|
+
const root = typeof window !== "undefined" ? window : (typeof globalThis !== "undefined" ? globalThis : global);
|
|
273
|
+
root.PiMarkdown = PiMarkdown;
|
|
274
|
+
root.escapeHtml = escapeHtml;
|
|
275
|
+
root.copyToClipboard = copyToClipboard;
|
|
276
|
+
root.sanitizeUrl = sanitizeUrl;
|
|
277
|
+
root.renderMarkdown = renderMarkdown;
|
|
278
|
+
})(typeof window !== "undefined" ? window : (typeof globalThis !== "undefined" ? globalThis : this));
|
package/public/style.css
CHANGED
|
@@ -734,6 +734,15 @@ body {
|
|
|
734
734
|
.btn-copy-code svg, .btn-copy-msg svg, .btn-copy-tool svg {
|
|
735
735
|
width: 12px;
|
|
736
736
|
height: 12px;
|
|
737
|
+
fill: none;
|
|
738
|
+
stroke: currentColor;
|
|
739
|
+
stroke-width: 2;
|
|
740
|
+
stroke-linecap: round;
|
|
741
|
+
stroke-linejoin: round;
|
|
742
|
+
flex-shrink: 0;
|
|
743
|
+
}
|
|
744
|
+
.btn-copy-code svg *, .btn-copy-msg svg *, .btn-copy-tool svg * {
|
|
745
|
+
fill: none;
|
|
737
746
|
}
|
|
738
747
|
.msg.assistant .content ul, .msg.assistant .content ol { margin: 8px 0 12px 24px; }
|
|
739
748
|
.msg.assistant .content li { margin: 4px 0; }
|