pi-web-ui 0.11.5 → 0.13.0
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 +441 -428
- package/README.zh-CN.md +367 -359
- package/dist/server/agent-service.js +168 -4
- package/package.json +1 -1
- package/web/dist/assets/index-BRdOlIsa.js +119 -0
- package/web/dist/assets/index-D5i4-IdY.css +41 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BZvXBQFP.css +0 -41
- package/web/dist/assets/index-CFs6gldr.js +0 -119
|
@@ -200,6 +200,32 @@ function looksLikeText(buf) {
|
|
|
200
200
|
}
|
|
201
201
|
return control / Math.max(text.length, 1) < 0.02;
|
|
202
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Cheap per-message discriminator for the serialization cache key. Persisted
|
|
205
|
+
* message content never changes, so this is stable across snapshots, while
|
|
206
|
+
* several same-role messages created within one millisecond (attachment
|
|
207
|
+
* asides) get distinct keys. Text blocks are fingerprinted by a short hash of
|
|
208
|
+
* their head (paths embedded in <file> tags can share long prefixes — e.g.
|
|
209
|
+
* uploads created in the same millisecond differ only at the tail); image
|
|
210
|
+
* payloads by data length (identical lengths within the same ms are far too
|
|
211
|
+
* unlikely to matter).
|
|
212
|
+
*/
|
|
213
|
+
function contentFingerprint(m) {
|
|
214
|
+
const content = m.content;
|
|
215
|
+
if (!Array.isArray(content) || content.length === 0)
|
|
216
|
+
return "empty";
|
|
217
|
+
const first = content[0];
|
|
218
|
+
if (first?.type === "image") {
|
|
219
|
+
return `img:${(first.data ?? "").length}`;
|
|
220
|
+
}
|
|
221
|
+
const text = typeof first?.text === "string" ? first.text : "";
|
|
222
|
+
// djb2 — fast enough to run per snapshot, distinct enough for asides.
|
|
223
|
+
let h = 5381;
|
|
224
|
+
for (let i = 0; i < text.length && i < 512; i++) {
|
|
225
|
+
h = ((h << 5) + h + text.charCodeAt(i)) >>> 0;
|
|
226
|
+
}
|
|
227
|
+
return `txt:${h.toString(36)}:${text.length}`;
|
|
228
|
+
}
|
|
203
229
|
/** First few KB of binary data as a classic hex + ASCII dump (preview only). */
|
|
204
230
|
function hexDump(buf, maxBytes = 4096) {
|
|
205
231
|
const data = buf.subarray(0, Math.min(buf.length, maxBytes));
|
|
@@ -899,9 +925,15 @@ export class ClientSession {
|
|
|
899
925
|
/** Serialize a persisted message with a STABLE id + cached object reference. */
|
|
900
926
|
serializeCached(m) {
|
|
901
927
|
const conv = this.conv;
|
|
928
|
+
// toolResult messages are keyed by toolCallId; everything else by
|
|
929
|
+
// role+timestamp. A single prompt can emit several same-role messages
|
|
930
|
+
// within the SAME millisecond (multiple attachment asides), so the
|
|
931
|
+
// timestamp alone collides in the cache and only the first one renders
|
|
932
|
+
// — append a cheap content fingerprint to keep them distinct while
|
|
933
|
+
// staying stable across snapshots (content never changes once persisted).
|
|
902
934
|
const key = m.role === "toolResult"
|
|
903
935
|
? `t:${m.toolCallId}`
|
|
904
|
-
: `${m.role}:${m.timestamp}`;
|
|
936
|
+
: `${m.role}:${m.timestamp}:${contentFingerprint(m)}`;
|
|
905
937
|
let n = conv.msgIds.get(key);
|
|
906
938
|
if (n === undefined) {
|
|
907
939
|
n = conv.nextMsgId++;
|
|
@@ -981,7 +1013,12 @@ export class ClientSession {
|
|
|
981
1013
|
: null,
|
|
982
1014
|
isStreaming: this.session.isStreaming,
|
|
983
1015
|
model: model
|
|
984
|
-
? {
|
|
1016
|
+
? {
|
|
1017
|
+
id: model.id,
|
|
1018
|
+
name: model.name,
|
|
1019
|
+
provider: model.provider,
|
|
1020
|
+
vision: model.input?.includes("image") ?? false,
|
|
1021
|
+
}
|
|
985
1022
|
: null,
|
|
986
1023
|
thinkingLevel: state.thinkingLevel,
|
|
987
1024
|
// Only the levels the current model actually supports — the SDK clamps
|
|
@@ -1610,13 +1647,17 @@ export class ClientSession {
|
|
|
1610
1647
|
* model sees them immediately; large files are passed as a <file path="...">
|
|
1611
1648
|
* reference and the model reads them on demand with its read tool (which has
|
|
1612
1649
|
* built-in truncation). Images are always passed as image content. Mode
|
|
1613
|
-
* "lines" inlines only a 1-based inclusive line range of the file.
|
|
1650
|
+
* "lines" inlines only a 1-based inclusive line range of the file. Raw
|
|
1651
|
+
* pasted/dropped/uploaded images (attachment.imageData) skip the workspace
|
|
1652
|
+
* path entirely and go straight to the model as image content. Raw uploaded
|
|
1653
|
+
* files (attachment.fileData) are persisted under <dataDir>/uploads/ and
|
|
1654
|
+
* attached as absolute-path references (small text ones are inlined).
|
|
1614
1655
|
*/
|
|
1615
1656
|
async buildAttachmentMessages(attachments) {
|
|
1616
1657
|
if (!attachments || attachments.length === 0)
|
|
1617
1658
|
return [];
|
|
1618
1659
|
const fs = await import("node:fs/promises");
|
|
1619
|
-
const { resolve, sep, relative, extname } = await import("node:path");
|
|
1660
|
+
const { resolve, sep, relative, extname, join } = await import("node:path");
|
|
1620
1661
|
const root = resolve(this.cwd);
|
|
1621
1662
|
const MAX_ATTACHMENT_BYTES = 200 * 1024;
|
|
1622
1663
|
// Files at or below this size are inlined; larger files are referenced by
|
|
@@ -1644,6 +1685,128 @@ export class ClientSession {
|
|
|
1644
1685
|
/** Cap for reading a file in "lines" mode (selected slice is inlined). */
|
|
1645
1686
|
const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
|
|
1646
1687
|
for (const att of attachments) {
|
|
1688
|
+
// Raw pasted/dropped/uploaded image — no workspace path involved (the
|
|
1689
|
+
// browser downscales client-side; this guard only prevents abuse).
|
|
1690
|
+
if (att.imageData) {
|
|
1691
|
+
const raw = att.imageData.replace(/^data:[^;]*;base64,/, "");
|
|
1692
|
+
const mimeType = att.mimeType?.startsWith("image/") ? att.mimeType : "image/png";
|
|
1693
|
+
const bytes = Buffer.byteLength(raw, "base64");
|
|
1694
|
+
const MAX_PASTED_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
1695
|
+
if (bytes === 0) {
|
|
1696
|
+
this.emit({
|
|
1697
|
+
type: "notice",
|
|
1698
|
+
level: "error",
|
|
1699
|
+
text: `图片数据为空,已跳过`,
|
|
1700
|
+
});
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
if (bytes > MAX_PASTED_IMAGE_BYTES) {
|
|
1704
|
+
this.emit({
|
|
1705
|
+
type: "notice",
|
|
1706
|
+
level: "warning",
|
|
1707
|
+
text: `图片过大已跳过(>2MB):${att.name ?? "粘贴图片"}`,
|
|
1708
|
+
});
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
out.push({
|
|
1712
|
+
message: {
|
|
1713
|
+
customType: "file",
|
|
1714
|
+
content: [{ type: "image", data: raw, mimeType }],
|
|
1715
|
+
display: true,
|
|
1716
|
+
details: {
|
|
1717
|
+
name: att.name ?? "image.png",
|
|
1718
|
+
// No workspace path — the card renders without the path line.
|
|
1719
|
+
path: undefined,
|
|
1720
|
+
mode: "image",
|
|
1721
|
+
size: bytes,
|
|
1722
|
+
},
|
|
1723
|
+
},
|
|
1724
|
+
});
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
// Raw uploaded file (base64) — no workspace path involved. The bytes are
|
|
1728
|
+
// persisted under <dataDir>/uploads/<clientId>/ so the model can read
|
|
1729
|
+
// them on demand with its read tool (absolute path, no traversal guard
|
|
1730
|
+
// needed — the path is server-generated). Small text uploads are inlined
|
|
1731
|
+
// so the model sees them immediately; everything else becomes a path
|
|
1732
|
+
// reference.
|
|
1733
|
+
if (att.fileData) {
|
|
1734
|
+
const buf = Buffer.from(att.fileData, "base64");
|
|
1735
|
+
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
1736
|
+
if (buf.length === 0) {
|
|
1737
|
+
this.emit({
|
|
1738
|
+
type: "notice",
|
|
1739
|
+
level: "error",
|
|
1740
|
+
text: `文件数据为空,已跳过`,
|
|
1741
|
+
});
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
if (buf.length > MAX_UPLOAD_BYTES) {
|
|
1745
|
+
this.emit({
|
|
1746
|
+
type: "notice",
|
|
1747
|
+
level: "warning",
|
|
1748
|
+
text: `文件过大已跳过(>20MB):${att.name ?? "上传文件"}`,
|
|
1749
|
+
});
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
// Uploaded files live in a GLOBAL per-user dir (not inside the project
|
|
1753
|
+
// or the per-client session store) so browsing a repo never picks up
|
|
1754
|
+
// uploaded junk: <home>/.pi-web/uploads/<clientId>/.
|
|
1755
|
+
const { homedir } = await import("node:os");
|
|
1756
|
+
const uploadsDir = join(homedir(), ".pi-web", "uploads", this.clientId);
|
|
1757
|
+
const safeName = (att.name ?? "file")
|
|
1758
|
+
.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_")
|
|
1759
|
+
.slice(0, 80);
|
|
1760
|
+
const abs = join(uploadsDir, `${Date.now()}-${safeName}`);
|
|
1761
|
+
await fs.mkdir(uploadsDir, { recursive: true });
|
|
1762
|
+
await fs.writeFile(abs, buf);
|
|
1763
|
+
// Wire format: forward-slash absolute path (the read tool accepts
|
|
1764
|
+
// absolute paths; Windows uses "C:/..." — safe inside the XML-ish tag).
|
|
1765
|
+
const wirePath = abs.split(sep).join("/");
|
|
1766
|
+
if (buf.length <= MAX_INLINE_BYTES && looksLikeText(buf)) {
|
|
1767
|
+
const lines = countLines(buf);
|
|
1768
|
+
out.push({
|
|
1769
|
+
message: {
|
|
1770
|
+
customType: "file",
|
|
1771
|
+
content: [
|
|
1772
|
+
{
|
|
1773
|
+
type: "text",
|
|
1774
|
+
text: `\n<file path="${wirePath}">\n\`\`\`\n${buf.toString("utf8")}\n\`\`\`\n</file>`,
|
|
1775
|
+
},
|
|
1776
|
+
],
|
|
1777
|
+
display: true,
|
|
1778
|
+
details: {
|
|
1779
|
+
name: safeName,
|
|
1780
|
+
path: wirePath,
|
|
1781
|
+
mode: "inline",
|
|
1782
|
+
size: buf.length,
|
|
1783
|
+
lines,
|
|
1784
|
+
},
|
|
1785
|
+
},
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
else {
|
|
1789
|
+
out.push({
|
|
1790
|
+
message: {
|
|
1791
|
+
customType: "file",
|
|
1792
|
+
content: [
|
|
1793
|
+
{
|
|
1794
|
+
type: "text",
|
|
1795
|
+
text: `<file path="${wirePath}" size="${buf.length}" />`,
|
|
1796
|
+
},
|
|
1797
|
+
],
|
|
1798
|
+
display: true,
|
|
1799
|
+
details: {
|
|
1800
|
+
name: safeName,
|
|
1801
|
+
path: wirePath,
|
|
1802
|
+
mode: "reference",
|
|
1803
|
+
size: buf.length,
|
|
1804
|
+
},
|
|
1805
|
+
},
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1647
1810
|
const abs = resolve(root, att.path);
|
|
1648
1811
|
const rawRel = relative(root, abs);
|
|
1649
1812
|
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
|
|
@@ -2543,6 +2706,7 @@ export class ClientSession {
|
|
|
2543
2706
|
name: m.name,
|
|
2544
2707
|
provider: m.provider,
|
|
2545
2708
|
reasoning: m.reasoning,
|
|
2709
|
+
vision: m.input?.includes("image") ?? false,
|
|
2546
2710
|
}));
|
|
2547
2711
|
this.emit({ type: "models", models });
|
|
2548
2712
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|