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.
@@ -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
- ? { id: model.id, name: model.name, provider: model.provider }
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.11.5",
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",