@liguoshuai/pi-web-chat 2.14.12 → 2.15.4
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 +29 -247
- package/public/index.html +1 -0
- package/public/markdown.js +263 -0
- package/public/style.css +6 -0
- package/docs/CHANGELOG.md +0 -990
- package/docs/ISSUES.md +0 -268
package/README.md
CHANGED
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构设计文档
|
|
2
2
|
|
|
3
|
-
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v2.14.
|
|
3
|
+
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v2.14.13 版本)。
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -164,7 +164,6 @@ Browser server.js pi RPC 子进程
|
|
|
164
164
|
pi-web-chat/
|
|
165
165
|
├── README.md # 主说明文档
|
|
166
166
|
├── package.json
|
|
167
|
-
├── package-lock.json
|
|
168
167
|
├── server.js # Express + WebSocket 服务器与 PiAgent 管理
|
|
169
168
|
├── bin/
|
|
170
169
|
│ └── pi-web-chat.js # 可执行入口
|
|
@@ -172,11 +171,9 @@ pi-web-chat/
|
|
|
172
171
|
│ ├── index.html # 单页 UI
|
|
173
172
|
│ ├── app.js # 前端逻辑与状态机
|
|
174
173
|
│ └── style.css # CSS 样式
|
|
175
|
-
├── docs/ #
|
|
174
|
+
├── docs/ # 本模块文档(CHANGELOG / ISSUES 已移至项目根 docs/)
|
|
176
175
|
│ ├── ARCHITECTURE.md # 架构设计文档(本文件)
|
|
177
|
-
│
|
|
178
|
-
│ ├── ISSUES.md # 排查与修补记录
|
|
179
|
-
│ └── CHANGELOG.md # 版本变更日志
|
|
176
|
+
│ └── DESIGN.md # 详细设计与决策文档
|
|
180
177
|
└── scripts/ # 服务安装/卸载脚本
|
|
181
178
|
├── install-service.sh
|
|
182
179
|
├── uninstall-service.sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.4",
|
|
4
4
|
"description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"express": "^4.21.2",
|
|
44
44
|
"ws": "^8.18.0",
|
|
45
|
-
"@liguoshuai/pi-chat-protocol": "2.
|
|
46
|
-
"@liguoshuai/pi-chat-server": "2.
|
|
45
|
+
"@liguoshuai/pi-chat-protocol": "2.15.4",
|
|
46
|
+
"@liguoshuai/pi-chat-server": "2.15.4"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
|
-
"build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/app.js",
|
|
49
|
+
"build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/markdown.js && node --check public/app.js",
|
|
50
50
|
"start": "node server.js",
|
|
51
51
|
"dev": "node --watch server.js",
|
|
52
52
|
"test": "node --test"
|
package/public/app.js
CHANGED
|
@@ -261,252 +261,22 @@ async function confirmCwdChange() {
|
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
// ---- Markdown
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
async function copyToClipboard(text) {
|
|
274
|
-
if (!text) return false;
|
|
275
|
-
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
264
|
+
// ---- Markdown & Clipboard helpers (modularized in /markdown.js) ----
|
|
265
|
+
// Fallbacks in case markdown.js failed to load
|
|
266
|
+
if (typeof escapeHtml === "undefined") {
|
|
267
|
+
window.escapeHtml = (s) => (s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
268
|
+
}
|
|
269
|
+
if (typeof copyToClipboard === "undefined") {
|
|
270
|
+
window.copyToClipboard = async (text) => {
|
|
271
|
+
if (!text) return false;
|
|
276
272
|
try {
|
|
277
|
-
await navigator.clipboard.writeText(text);
|
|
278
|
-
|
|
279
|
-
} catch (e) {
|
|
280
|
-
console.warn("navigator.clipboard.writeText failed:", e);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
try {
|
|
284
|
-
const ta = document.createElement("textarea");
|
|
285
|
-
ta.value = text;
|
|
286
|
-
ta.style.position = "fixed";
|
|
287
|
-
ta.style.left = "-9999px";
|
|
288
|
-
ta.style.top = "-9999px";
|
|
289
|
-
document.body.appendChild(ta);
|
|
290
|
-
ta.focus();
|
|
291
|
-
ta.select();
|
|
292
|
-
const ok = document.execCommand("copy");
|
|
293
|
-
document.body.removeChild(ta);
|
|
294
|
-
return ok;
|
|
295
|
-
} catch (e) {
|
|
296
|
-
console.error("Fallback copy error:", e);
|
|
273
|
+
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
|
|
274
|
+
} catch {}
|
|
297
275
|
return false;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function renderMarkdown(md) {
|
|
302
|
-
if (!md) return "";
|
|
303
|
-
if (typeof md !== "string") md = String(md);
|
|
304
|
-
// Strip headings of # etc. and convert to proper elements with escaping.
|
|
305
|
-
// We do a fenced-code-first approach so we don't process markdown inside code.
|
|
306
|
-
const parts = [];
|
|
307
|
-
let rest = md;
|
|
308
|
-
while (rest.length) {
|
|
309
|
-
// Only match ``` at the beginning of a line (or start of string) to avoid
|
|
310
|
-
// misinterpreting inline triple-backticks as code fences.
|
|
311
|
-
const fenceMatch = rest.match(/(?:^|\n)```/);
|
|
312
|
-
if (!fenceMatch) {
|
|
313
|
-
parts.push({ kind: "md", text: rest });
|
|
314
|
-
rest = "";
|
|
315
|
-
} else {
|
|
316
|
-
const fenceIdx = fenceMatch.index + (fenceMatch[0].length - 3);
|
|
317
|
-
if (fenceIdx > 0) parts.push({ kind: "md", text: rest.slice(0, fenceIdx) });
|
|
318
|
-
rest = rest.slice(fenceIdx + 3);
|
|
319
|
-
// optional language on this line
|
|
320
|
-
const nl = rest.indexOf("\n");
|
|
321
|
-
let lang = "";
|
|
322
|
-
if (nl !== -1) {
|
|
323
|
-
const firstLine = rest.slice(0, nl).trim();
|
|
324
|
-
if (firstLine && !firstLine.includes("```")) lang = firstLine;
|
|
325
|
-
rest = rest.slice(nl + 1);
|
|
326
|
-
}
|
|
327
|
-
const closeIdx = rest.indexOf("```");
|
|
328
|
-
let code;
|
|
329
|
-
if (closeIdx === -1) { code = rest; rest = ""; }
|
|
330
|
-
else { code = rest.slice(0, closeIdx); rest = rest.slice(closeIdx + 3).replace(/^\n/, ""); }
|
|
331
|
-
parts.push({ kind: "code", lang, code });
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
let html = "";
|
|
335
|
-
for (const p of parts) {
|
|
336
|
-
if (p.kind === "code") {
|
|
337
|
-
const lang = p.lang || "";
|
|
338
|
-
const displayLang = lang || "code";
|
|
339
|
-
html += `<div class="code-block-wrapper">` +
|
|
340
|
-
`<div class="code-block-header">` +
|
|
341
|
-
`<span class="code-block-lang">${escapeHtml(displayLang)}</span>` +
|
|
342
|
-
`<button class="btn-copy-code" type="button" aria-label="复制代码" title="复制代码">` +
|
|
343
|
-
`<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>` +
|
|
344
|
-
`<span>复制</span>` +
|
|
345
|
-
`</button>` +
|
|
346
|
-
`</div>` +
|
|
347
|
-
`<pre><code data-lang="${escapeHtml(lang)}">${escapeHtml(p.code)}</code></pre>` +
|
|
348
|
-
`</div>`;
|
|
349
|
-
} else {
|
|
350
|
-
html += renderInlineMd(p.text);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
return html;
|
|
276
|
+
};
|
|
354
277
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// tables, then markdown-ish transforms. Escape first.
|
|
358
|
-
// Split out inline code first using placeholders to protect them.
|
|
359
|
-
const codeChunks = [];
|
|
360
|
-
let t = text.replace(/`([^`\n]+)`/g, (m) => {
|
|
361
|
-
const i = codeChunks.length;
|
|
362
|
-
codeChunks.push(m);
|
|
363
|
-
return `\u0000CODE${i}\u0000`;
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
// Tables: a block of consecutive lines delimited by blank lines,
|
|
367
|
-
// where the second line is like |---|---|.
|
|
368
|
-
const lines = t.split("\n");
|
|
369
|
-
const out = [];
|
|
370
|
-
let i = 0;
|
|
371
|
-
while (i < lines.length) {
|
|
372
|
-
if (lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?[\s\-:|]+\|?\s*$/.test(lines[i + 1]) && lines[i+1].includes("-")) {
|
|
373
|
-
// collect table block
|
|
374
|
-
const header = lines[i];
|
|
375
|
-
const tblLines = [header, lines[i + 1]];
|
|
376
|
-
let j = i + 2;
|
|
377
|
-
while (j < lines.length && lines[j].includes("|")) { tblLines.push(lines[j]); j++; }
|
|
378
|
-
out.push({ kind: "table", lines: tblLines });
|
|
379
|
-
i = j;
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
out.push({ kind: "line", text: lines[i] });
|
|
383
|
-
i++;
|
|
384
|
-
}
|
|
385
|
-
let outHtml = "";
|
|
386
|
-
let para = [];
|
|
387
|
-
let linkListOpen = null;
|
|
388
|
-
let linkListOrdered = null;
|
|
389
|
-
|
|
390
|
-
function flushList() {
|
|
391
|
-
if (linkListOpen) {
|
|
392
|
-
outHtml += linkListOpen === "ol" ? "</ol>" : "</ul>";
|
|
393
|
-
linkListOpen = null;
|
|
394
|
-
linkListOrdered = null;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function flushPara() {
|
|
399
|
-
if (para.length === 0) return;
|
|
400
|
-
const block = para.join("\n").trim();
|
|
401
|
-
para = [];
|
|
402
|
-
outHtml += "<p>" + mdInlineBlock(block).replace(/\n/g, "<br>") + "</p>";
|
|
403
|
-
}
|
|
404
|
-
for (const seg of out) {
|
|
405
|
-
if (seg.kind === "table") {
|
|
406
|
-
flushPara();
|
|
407
|
-
flushList();
|
|
408
|
-
outHtml += mdTable(seg.lines);
|
|
409
|
-
} else if (seg.kind === "line") {
|
|
410
|
-
// horizontal rule
|
|
411
|
-
if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
|
|
412
|
-
flushPara();
|
|
413
|
-
flushList();
|
|
414
|
-
outHtml += "<hr>";
|
|
415
|
-
} else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
|
|
416
|
-
// headings
|
|
417
|
-
const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
|
|
418
|
-
flushPara();
|
|
419
|
-
flushList();
|
|
420
|
-
const level = m[1].length;
|
|
421
|
-
outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
|
|
422
|
-
} else if (/^\s*$/.test(seg.text)) {
|
|
423
|
-
flushPara();
|
|
424
|
-
flushList();
|
|
425
|
-
} else if (/^>\s?/.test(seg.text)) {
|
|
426
|
-
// blockquote line — group simple consecutive ones
|
|
427
|
-
flushPara();
|
|
428
|
-
flushList();
|
|
429
|
-
outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
|
|
430
|
-
} else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
|
|
431
|
-
// list item — group consecutive into ul/ol
|
|
432
|
-
// simple inline handling: wrap each list item line.
|
|
433
|
-
const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
|
|
434
|
-
if (!linkListOpen || linkListOrdered !== isOrdered) {
|
|
435
|
-
flushPara();
|
|
436
|
-
flushList();
|
|
437
|
-
linkListOpen = isOrdered ? "ol" : "ul";
|
|
438
|
-
linkListOrdered = isOrdered;
|
|
439
|
-
outHtml += "<" + linkListOpen + ">";
|
|
440
|
-
}
|
|
441
|
-
let itemText = seg.text.replace(/^\s*([-*+]|\d+\.)\s+/, "");
|
|
442
|
-
let taskPrefix = "";
|
|
443
|
-
if (/^\[ \]\s+/.test(itemText)) {
|
|
444
|
-
taskPrefix = '<input type="checkbox" disabled class="task-list-item-checkbox"> ';
|
|
445
|
-
itemText = itemText.replace(/^\[ \]\s+/, "");
|
|
446
|
-
} else if (/^\[[xX]\]\s+/.test(itemText)) {
|
|
447
|
-
taskPrefix = '<input type="checkbox" checked disabled class="task-list-item-checkbox"> ';
|
|
448
|
-
itemText = itemText.replace(/^\[[xX]\]\s+/, "");
|
|
449
|
-
}
|
|
450
|
-
outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
|
|
451
|
-
} else {
|
|
452
|
-
flushList();
|
|
453
|
-
para.push(seg.text);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
flushList();
|
|
458
|
-
flushPara();
|
|
459
|
-
// restore inline code
|
|
460
|
-
outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => {
|
|
461
|
-
const chunk = codeChunks[+n];
|
|
462
|
-
return chunk ? `<code>${escapeHtml(chunk.slice(1, -1))}</code>` : "";
|
|
463
|
-
});
|
|
464
|
-
return outHtml;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function sanitizeUrl(url) {
|
|
468
|
-
if (!url) return "#";
|
|
469
|
-
const trimmed = url.trim();
|
|
470
|
-
if (/^(https?:\/\/|mailto:)/i.test(trimmed)) {
|
|
471
|
-
return trimmed.replace(/"/g, """).replace(/'/g, "'");
|
|
472
|
-
}
|
|
473
|
-
return "#";
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// helper state bag attached to the function during line scan
|
|
477
|
-
function mdInlineBlock(text) {
|
|
478
|
-
let s = escapeHtml(text);
|
|
479
|
-
// bold
|
|
480
|
-
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
481
|
-
s = s.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
|
482
|
-
// strikethrough
|
|
483
|
-
s = s.replace(/~~(.+?)~~/g, "<del>$1</del>");
|
|
484
|
-
// italic (asterisk)
|
|
485
|
-
s = s.replace(/(^|[^*])\*([^*\s](?:[^*]*?[^*\s])?)\*(?!\*)/g, "$1<em>$2</em>");
|
|
486
|
-
// italic (underscore): only match boundary/space so identifier_names are preserved
|
|
487
|
-
s = s.replace(/(^|[\s(\[<,;:])_([^_\s](?:[^_]*?[^_\s])?)_(?=[\s)\]>,;:!?.]|$)/g, "$1<em>$2</em>");
|
|
488
|
-
// links [txt](url) - strictly sanitize URL
|
|
489
|
-
s = s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)/g, (match, txt, url) => {
|
|
490
|
-
const safeUrl = sanitizeUrl(url);
|
|
491
|
-
return `<a href="${safeUrl}" target="_blank" rel="noopener">${txt}</a>`;
|
|
492
|
-
});
|
|
493
|
-
return s;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function mdTable(lines) {
|
|
497
|
-
const parseRow = (l) => l.split("|").map(c => c.trim()).filter((_, i, arr) => !(i === 0 && arr[0] === "") && !(i === arr.length - 1 && arr[arr.length - 1] === ""));
|
|
498
|
-
const header = parseRow(lines[0]);
|
|
499
|
-
const body = lines.slice(2).filter(l => l.trim()).map(parseRow);
|
|
500
|
-
let h = '<table><thead><tr>';
|
|
501
|
-
header.forEach((c) => h += `<th>${mdInlineBlock(c)}</th>`);
|
|
502
|
-
h += '</tr></thead><tbody>';
|
|
503
|
-
body.forEach((r) => {
|
|
504
|
-
h += '<tr>';
|
|
505
|
-
r.forEach((c) => h += `<td>${mdInlineBlock(c)}</td>`);
|
|
506
|
-
h += '</tr>';
|
|
507
|
-
});
|
|
508
|
-
h += '</tbody></table>';
|
|
509
|
-
return h;
|
|
278
|
+
if (typeof renderMarkdown === "undefined") {
|
|
279
|
+
window.renderMarkdown = (md) => escapeHtml(md || "").replace(/\n/g, "<br>");
|
|
510
280
|
}
|
|
511
281
|
|
|
512
282
|
// ---- DOM helpers ----
|
|
@@ -530,6 +300,18 @@ const el = (tag, props = {}, children = []) => {
|
|
|
530
300
|
return n;
|
|
531
301
|
};
|
|
532
302
|
|
|
303
|
+
const makeCopyIconSvg = () => el("svg", {
|
|
304
|
+
viewBox: "0 0 24 24",
|
|
305
|
+
width: "12",
|
|
306
|
+
height: "12",
|
|
307
|
+
fill: "none",
|
|
308
|
+
stroke: "currentColor",
|
|
309
|
+
"stroke-width": "2",
|
|
310
|
+
"stroke-linecap": "round",
|
|
311
|
+
"stroke-linejoin": "round",
|
|
312
|
+
html: '<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>'
|
|
313
|
+
});
|
|
314
|
+
|
|
533
315
|
// ---- Message Time Formatter ----
|
|
534
316
|
function formatMessageTime(ts) {
|
|
535
317
|
if (!ts) return "";
|
|
@@ -1366,7 +1148,7 @@ function renderAssistantBlock(m) {
|
|
|
1366
1148
|
}
|
|
1367
1149
|
}
|
|
1368
1150
|
}, [
|
|
1369
|
-
|
|
1151
|
+
makeCopyIconSvg(),
|
|
1370
1152
|
el("span", { text: "复制全文" })
|
|
1371
1153
|
]);
|
|
1372
1154
|
|
|
@@ -1490,7 +1272,7 @@ function updateToolBlockCopyBtn(tc, call) {
|
|
|
1490
1272
|
}
|
|
1491
1273
|
}
|
|
1492
1274
|
}, [
|
|
1493
|
-
|
|
1275
|
+
makeCopyIconSvg(),
|
|
1494
1276
|
el("span", { text: "复制" })
|
|
1495
1277
|
]);
|
|
1496
1278
|
const stateEl = tc.head.querySelector(".state");
|
|
@@ -1547,7 +1329,7 @@ function makeToolBlockFromCall(call, ts = null) {
|
|
|
1547
1329
|
}
|
|
1548
1330
|
}
|
|
1549
1331
|
}, [
|
|
1550
|
-
|
|
1332
|
+
makeCopyIconSvg(),
|
|
1551
1333
|
el("span", { text: "复制" })
|
|
1552
1334
|
]) : null;
|
|
1553
1335
|
|
|
@@ -1641,7 +1423,7 @@ function ensureStreamingMsg(ts = Date.now()) {
|
|
|
1641
1423
|
}
|
|
1642
1424
|
}
|
|
1643
1425
|
}, [
|
|
1644
|
-
|
|
1426
|
+
makeCopyIconSvg(),
|
|
1645
1427
|
el("span", { text: "复制全文" })
|
|
1646
1428
|
]);
|
|
1647
1429
|
|
package/public/index.html
CHANGED
|
@@ -0,0 +1,263 @@
|
|
|
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 linkListOpen = null;
|
|
115
|
+
let linkListOrdered = null;
|
|
116
|
+
|
|
117
|
+
function flushList() {
|
|
118
|
+
if (linkListOpen) {
|
|
119
|
+
outHtml += linkListOpen === "ol" ? "</ol>" : "</ul>";
|
|
120
|
+
linkListOpen = null;
|
|
121
|
+
linkListOrdered = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function flushPara() {
|
|
126
|
+
if (para.length === 0) return;
|
|
127
|
+
const block = para.join("\n").trim();
|
|
128
|
+
para = [];
|
|
129
|
+
outHtml += "<p>" + mdInlineBlock(block).replace(/\n/g, "<br>") + "</p>";
|
|
130
|
+
}
|
|
131
|
+
for (const seg of out) {
|
|
132
|
+
if (seg.kind === "table") {
|
|
133
|
+
flushPara();
|
|
134
|
+
flushList();
|
|
135
|
+
outHtml += mdTable(seg.lines);
|
|
136
|
+
} else if (seg.kind === "line") {
|
|
137
|
+
// horizontal rule
|
|
138
|
+
if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
|
|
139
|
+
flushPara();
|
|
140
|
+
flushList();
|
|
141
|
+
outHtml += "<hr>";
|
|
142
|
+
} else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
|
|
143
|
+
// headings
|
|
144
|
+
const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
|
|
145
|
+
flushPara();
|
|
146
|
+
flushList();
|
|
147
|
+
const level = m[1].length;
|
|
148
|
+
outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
|
|
149
|
+
} else if (/^\s*$/.test(seg.text)) {
|
|
150
|
+
flushPara();
|
|
151
|
+
flushList();
|
|
152
|
+
} else if (/^>\s?/.test(seg.text)) {
|
|
153
|
+
// blockquote line
|
|
154
|
+
flushPara();
|
|
155
|
+
flushList();
|
|
156
|
+
outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
|
|
157
|
+
} else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
|
|
158
|
+
// list item
|
|
159
|
+
const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
|
|
160
|
+
if (!linkListOpen || linkListOrdered !== isOrdered) {
|
|
161
|
+
flushPara();
|
|
162
|
+
flushList();
|
|
163
|
+
linkListOpen = isOrdered ? "ol" : "ul";
|
|
164
|
+
linkListOrdered = isOrdered;
|
|
165
|
+
outHtml += "<" + linkListOpen + ">";
|
|
166
|
+
}
|
|
167
|
+
let itemText = seg.text.replace(/^\s*([-*+]|\d+\.)\s+/, "");
|
|
168
|
+
let taskPrefix = "";
|
|
169
|
+
if (/^\[ \]\s+/.test(itemText)) {
|
|
170
|
+
taskPrefix = '<input type="checkbox" disabled class="task-list-item-checkbox"> ';
|
|
171
|
+
itemText = itemText.replace(/^\[ \]\s+/, "");
|
|
172
|
+
} else if (/^\[[xX]\]\s+/.test(itemText)) {
|
|
173
|
+
taskPrefix = '<input type="checkbox" checked disabled class="task-list-item-checkbox"> ';
|
|
174
|
+
itemText = itemText.replace(/^\[[xX]\]\s+/, "");
|
|
175
|
+
}
|
|
176
|
+
outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
|
|
177
|
+
} else {
|
|
178
|
+
flushList();
|
|
179
|
+
para.push(seg.text);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
flushList();
|
|
184
|
+
flushPara();
|
|
185
|
+
// restore inline code
|
|
186
|
+
outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => {
|
|
187
|
+
const chunk = codeChunks[+n];
|
|
188
|
+
return chunk ? `<code>${escapeHtml(chunk.slice(1, -1))}</code>` : "";
|
|
189
|
+
});
|
|
190
|
+
return outHtml;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderMarkdown(md) {
|
|
194
|
+
if (!md) return "";
|
|
195
|
+
if (typeof md !== "string") md = String(md);
|
|
196
|
+
const parts = [];
|
|
197
|
+
let rest = md;
|
|
198
|
+
while (rest.length) {
|
|
199
|
+
const fenceMatch = rest.match(/(?:^|\n)```/);
|
|
200
|
+
if (!fenceMatch) {
|
|
201
|
+
parts.push({ kind: "md", text: rest });
|
|
202
|
+
rest = "";
|
|
203
|
+
} else {
|
|
204
|
+
const fenceIdx = fenceMatch.index + (fenceMatch[0].length - 3);
|
|
205
|
+
if (fenceIdx > 0) parts.push({ kind: "md", text: rest.slice(0, fenceIdx) });
|
|
206
|
+
rest = rest.slice(fenceIdx + 3);
|
|
207
|
+
const nl = rest.indexOf("\n");
|
|
208
|
+
let lang = "";
|
|
209
|
+
if (nl !== -1) {
|
|
210
|
+
const firstLine = rest.slice(0, nl).trim();
|
|
211
|
+
if (firstLine && !firstLine.includes("```")) lang = firstLine;
|
|
212
|
+
rest = rest.slice(nl + 1);
|
|
213
|
+
}
|
|
214
|
+
const closeIdx = rest.indexOf("```");
|
|
215
|
+
let code;
|
|
216
|
+
if (closeIdx === -1) { code = rest; rest = ""; }
|
|
217
|
+
else { code = rest.slice(0, closeIdx); rest = rest.slice(closeIdx + 3).replace(/^\n/, ""); }
|
|
218
|
+
parts.push({ kind: "code", lang, code });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
let html = "";
|
|
222
|
+
for (const p of parts) {
|
|
223
|
+
if (p.kind === "code") {
|
|
224
|
+
const lang = p.lang || "";
|
|
225
|
+
const displayLang = lang || "code";
|
|
226
|
+
html += `<div class="code-block-wrapper">` +
|
|
227
|
+
`<div class="code-block-header">` +
|
|
228
|
+
`<span class="code-block-lang">${escapeHtml(displayLang)}</span>` +
|
|
229
|
+
`<button class="btn-copy-code" type="button" aria-label="复制代码" title="复制代码">` +
|
|
230
|
+
`<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>` +
|
|
231
|
+
`<span>复制</span>` +
|
|
232
|
+
`</button>` +
|
|
233
|
+
`</div>` +
|
|
234
|
+
`<pre><code data-lang="${escapeHtml(lang)}">${escapeHtml(p.code)}</code></pre>` +
|
|
235
|
+
`</div>`;
|
|
236
|
+
} else {
|
|
237
|
+
html += renderInlineMd(p.text);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return html;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Export to global scope & Node/module exports
|
|
244
|
+
const PiMarkdown = {
|
|
245
|
+
escapeHtml,
|
|
246
|
+
copyToClipboard,
|
|
247
|
+
sanitizeUrl,
|
|
248
|
+
mdInlineBlock,
|
|
249
|
+
mdTable,
|
|
250
|
+
renderInlineMd,
|
|
251
|
+
renderMarkdown
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
if (typeof exports === "object" && typeof module !== "undefined") {
|
|
255
|
+
module.exports = PiMarkdown;
|
|
256
|
+
}
|
|
257
|
+
const root = typeof window !== "undefined" ? window : (typeof globalThis !== "undefined" ? globalThis : global);
|
|
258
|
+
root.PiMarkdown = PiMarkdown;
|
|
259
|
+
root.escapeHtml = escapeHtml;
|
|
260
|
+
root.copyToClipboard = copyToClipboard;
|
|
261
|
+
root.sanitizeUrl = sanitizeUrl;
|
|
262
|
+
root.renderMarkdown = renderMarkdown;
|
|
263
|
+
})(typeof window !== "undefined" ? window : (typeof globalThis !== "undefined" ? globalThis : this));
|
package/public/style.css
CHANGED
|
@@ -734,6 +734,12 @@ 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;
|
|
737
743
|
}
|
|
738
744
|
.msg.assistant .content ul, .msg.assistant .content ol { margin: 8px 0 12px 24px; }
|
|
739
745
|
.msg.assistant .content li { margin: 4px 0; }
|