@liguoshuai/pi-web-chat 1.10.2 → 1.10.3

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/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.10.3] - 2026-08-30
11
+
12
+ ### Fixed & Improved
13
+ - **移动端附件导入功能全面重构与修复 (Mobile Attachment Import Fix)**:
14
+ - **移动端文件选择器唤起修复**:将上传触发器重构为标准可访问的 `<label for="imageFileInput">` 并配合 CSS `.sr-only-file-input`,彻底解决 iOS Safari、Android Chrome、移动端 WebView 及微信内置浏览器中因 `display: none` 导致异步 JS `.click()` 被浏览器安全策略拦截而无法调起系统相册/文件选择器的问题。
15
+ - **移动端格式与相机照片兼容性**:扩展对 iOS HEIC/HEIF 相机原图、AVIF、ICO、SVG 及大写扩展名的识别与支持。
16
+ - **移动端高清原图智能压缩降采样 (Client-side Downscale)**:针对手机拍摄的超高分辨率照片(12MP~48MP),自动使用 Canvas 进行等比下采样(最大 2048px)与高质量编码压缩,防止超大 Base64 数据挤爆移动端内存或造成 WebSocket 通信中断,同时确保所有主流大模型 Vision API 稳定解析。
17
+ - **文本与源码文件附件导入支持 (Text & Code Attachment Parsing)**:支持通过附件按钮、拖拽或剪贴板直接导入 `.py`、`.js`、`.json`、`.md`、`.txt`、`.log` 等各类代码和文本附件,自动提取并按对应语言 Markdown 代码块注入输入框。
18
+ - **预览交互与触控体验优化 (Touch UX & Preview Lightbox)**:输入框待发送图片缩略图支持点击直接弹出 Lightbox 大图预览,加大移动端单张删除按钮的触控热区(22px)与操作反馈。
19
+
20
+ ---
21
+
10
22
  ## [1.10.2] - 2026-08-30
11
23
 
12
24
  ### Fixed & Improved
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.10.2",
3
+ "version": "1.10.3",
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",
package/public/app.js CHANGED
@@ -812,7 +812,11 @@ function renderImagePreviews() {
812
812
  }
813
813
  bar.style.display = "flex";
814
814
  state.attachedImages.forEach((img, idx) => {
815
- const item = el("div", { class: "image-preview-item" }, [
815
+ const item = el("div", {
816
+ class: "image-preview-item",
817
+ title: "点击查看大图",
818
+ onclick: () => openLightbox(img.url)
819
+ }, [
816
820
  el("img", { src: img.url, alt: "预览" }),
817
821
  el("button", {
818
822
  class: "image-preview-remove",
@@ -840,38 +844,173 @@ function detectImageMimeType(file) {
840
844
  bmp: "image/bmp",
841
845
  svg: "image/svg+xml",
842
846
  ico: "image/x-icon",
843
- avif: "image/avif"
847
+ avif: "image/avif",
848
+ heic: "image/heic",
849
+ heif: "image/heif"
844
850
  };
845
851
  return map[ext] || "image/png";
846
852
  }
847
853
 
848
- function handleImageFiles(files) {
849
- if (!files || files.length === 0) return;
850
- const imageFiles = Array.from(files).filter(f => {
851
- return (f.type && f.type.startsWith("image/")) ||
852
- (f.name && /\.(png|jpe?g|webp|gif|bmp|svg|ico|avif)$/i.test(f.name));
853
- });
854
- if (imageFiles.length === 0) return;
855
-
856
- imageFiles.forEach(file => {
854
+ async function processImageFile(file) {
855
+ const mimeType = detectImageMimeType(file);
856
+ const dataUrl = await new Promise((resolve, reject) => {
857
857
  const reader = new FileReader();
858
- reader.onload = () => {
859
- const result = reader.result;
860
- if (typeof result !== "string") return;
861
- const commaIdx = result.indexOf(",");
862
- const base64 = commaIdx !== -1 ? result.slice(commaIdx + 1) : result;
863
- const mimeType = detectImageMimeType(file);
864
- state.attachedImages.push({
865
- data: base64,
866
- mimeType,
867
- url: result
868
- });
869
- renderImagePreviews();
870
- };
858
+ reader.onload = () => resolve(reader.result);
859
+ reader.onerror = reject;
871
860
  reader.readAsDataURL(file);
872
861
  });
862
+
863
+ if (typeof dataUrl !== "string") return null;
864
+
865
+ // For SVG or GIF (which might be animated), or small images, keep original
866
+ if (mimeType.includes("svg") || mimeType.includes("gif") || (file.size && file.size < 800 * 1024)) {
867
+ const commaIdx = dataUrl.indexOf(",");
868
+ const base64 = commaIdx !== -1 ? dataUrl.slice(commaIdx + 1) : dataUrl;
869
+ return { data: base64, mimeType, url: dataUrl };
870
+ }
871
+
872
+ // Optimize large phone camera photos / oversized images via canvas downscale
873
+ try {
874
+ const img = await new Promise((resolve, reject) => {
875
+ const i = new Image();
876
+ i.onload = () => resolve(i);
877
+ i.onerror = reject;
878
+ i.src = dataUrl;
879
+ });
880
+
881
+ const maxDim = 2048;
882
+ let { width, height } = img;
883
+ if (width > maxDim || height > maxDim) {
884
+ if (width > height) {
885
+ height = Math.round((height * maxDim) / width);
886
+ width = maxDim;
887
+ } else {
888
+ width = Math.round((width * maxDim) / height);
889
+ height = maxDim;
890
+ }
891
+ }
892
+
893
+ const canvas = document.createElement("canvas");
894
+ canvas.width = width;
895
+ canvas.height = height;
896
+ const ctx = canvas.getContext("2d");
897
+ if (!ctx) throw new Error("Canvas 2D unavailable");
898
+ ctx.drawImage(img, 0, 0, width, height);
899
+
900
+ const targetMime = (mimeType === "image/png" && file.size < 2 * 1024 * 1024) ? "image/png" : "image/jpeg";
901
+ const quality = 0.88;
902
+ const optimizedUrl = canvas.toDataURL(targetMime, quality);
903
+ const commaIdx = optimizedUrl.indexOf(",");
904
+ const base64 = commaIdx !== -1 ? optimizedUrl.slice(commaIdx + 1) : optimizedUrl;
905
+
906
+ return {
907
+ data: base64,
908
+ mimeType: targetMime,
909
+ url: optimizedUrl
910
+ };
911
+ } catch {
912
+ // Fallback to original base64 if canvas processing is unsupported
913
+ const commaIdx = dataUrl.indexOf(",");
914
+ const base64 = commaIdx !== -1 ? dataUrl.slice(commaIdx + 1) : dataUrl;
915
+ return { data: base64, mimeType, url: dataUrl };
916
+ }
917
+ }
918
+
919
+ function getLanguageFromFilename(filename) {
920
+ if (!filename) return "";
921
+ const ext = filename.split(".").pop().toLowerCase();
922
+ const langMap = {
923
+ js: "javascript", mjs: "javascript", cjs: "javascript",
924
+ ts: "typescript", tsx: "tsx", jsx: "jsx",
925
+ py: "python", pyw: "python",
926
+ rb: "ruby", rs: "rust", go: "go", java: "java",
927
+ c: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", h: "c", hpp: "cpp",
928
+ sh: "bash", bash: "bash", zsh: "bash",
929
+ json: "json", yaml: "yaml", yml: "yaml", toml: "toml",
930
+ md: "markdown", markdown: "markdown",
931
+ html: "html", htm: "html", css: "css", scss: "scss", less: "less",
932
+ sql: "sql", xml: "xml", svg: "xml",
933
+ log: "log", env: "ini", ini: "ini", conf: "ini",
934
+ diff: "diff", patch: "diff", dockerfile: "dockerfile", makefile: "makefile"
935
+ };
936
+ return langMap[ext] || "";
937
+ }
938
+
939
+ function isTextFile(file) {
940
+ if (file.type && (file.type.startsWith("text/") || file.type.includes("json") || file.type.includes("xml") || file.type.includes("javascript") || file.type.includes("yaml"))) {
941
+ return true;
942
+ }
943
+ const name = file.name ? file.name.toLowerCase() : "";
944
+ return /\.(txt|md|markdown|json|js|mjs|cjs|ts|tsx|jsx|py|pyw|rb|php|java|c|cpp|cc|cxx|h|hpp|rs|go|sh|bash|zsh|sql|html|htm|css|scss|sass|less|vue|svelte|yaml|yml|toml|ini|env|xml|log|csv|tsv|diff|patch|dockerfile|makefile)$/i.test(name);
945
+ }
946
+
947
+ async function handleIncomingFiles(files) {
948
+ if (!files || files.length === 0) return;
949
+ const list = Array.from(files);
950
+
951
+ let imageCount = 0;
952
+ let textCount = 0;
953
+
954
+ for (const file of list) {
955
+ const isImg = (file.type && file.type.startsWith("image/")) ||
956
+ (file.name && /\.(png|jpe?g|webp|gif|bmp|svg|ico|avif|heic|heif)$/i.test(file.name));
957
+
958
+ if (isImg) {
959
+ try {
960
+ const imgObj = await processImageFile(file);
961
+ if (imgObj) {
962
+ state.attachedImages.push(imgObj);
963
+ renderImagePreviews();
964
+ imageCount++;
965
+ }
966
+ } catch (err) {
967
+ console.error("Failed to process image file:", err);
968
+ }
969
+ } else if (isTextFile(file)) {
970
+ if (file.size > 1024 * 1024) {
971
+ showToast(`文件 ${file.name} 较大,建议放入工作目录供 pi 访问`);
972
+ continue;
973
+ }
974
+ try {
975
+ const content = await new Promise((resolve, reject) => {
976
+ const reader = new FileReader();
977
+ reader.onload = () => resolve(reader.result);
978
+ reader.onerror = reject;
979
+ reader.readAsText(file);
980
+ });
981
+ if (typeof content === "string") {
982
+ const lang = getLanguageFromFilename(file.name);
983
+ const composer = $("#composer");
984
+ if (composer) {
985
+ const block = `[附件: ${file.name}]\n\`\`\`${lang}\n${content}\n\`\`\`\n`;
986
+ if (composer.value.trim()) {
987
+ composer.value = composer.value.trimEnd() + "\n\n" + block;
988
+ } else {
989
+ composer.value = block;
990
+ }
991
+ autoResize();
992
+ composer.focus();
993
+ textCount++;
994
+ }
995
+ }
996
+ } catch (err) {
997
+ console.error("Failed to read text file:", err);
998
+ }
999
+ } else {
1000
+ showToast(`暂不支持直接解析该附件格式 (${file.name || "未知类型"})`);
1001
+ }
1002
+ }
1003
+
1004
+ if (imageCount > 0) {
1005
+ showToast(`已添加 ${imageCount} 张图片附件`);
1006
+ }
1007
+ if (textCount > 0) {
1008
+ showToast(`已导入 ${textCount} 个文本文件`);
1009
+ }
873
1010
  }
874
1011
 
1012
+ const handleImageFiles = handleIncomingFiles;
1013
+
875
1014
  function exportCurrentSession() {
876
1015
  const chatInner = $("#chat-inner");
877
1016
  if (!chatInner || chatInner.children.length === 0) {
@@ -2714,30 +2853,43 @@ async function init() {
2714
2853
  exportBtn.addEventListener("click", exportCurrentSession);
2715
2854
  }
2716
2855
 
2717
- // Image attach / file picker / paste / drag-and-drop
2856
+ // Image and file attach / picker / paste / drag-and-drop
2718
2857
  const btnAttach = $("#btnAttachImage");
2719
2858
  const fileInput = $("#imageFileInput");
2720
- if (btnAttach && fileInput) {
2721
- btnAttach.addEventListener("click", () => fileInput.click());
2859
+ if (fileInput) {
2722
2860
  fileInput.addEventListener("change", (e) => {
2723
- handleImageFiles(e.target.files);
2861
+ handleIncomingFiles(e.target.files);
2724
2862
  fileInput.value = "";
2725
2863
  });
2726
2864
  }
2727
2865
 
2728
- // Image paste support
2866
+ if (btnAttach) {
2867
+ // If not a label (e.g. fallback button), click input
2868
+ if (btnAttach.tagName !== "LABEL") {
2869
+ btnAttach.addEventListener("click", () => fileInput?.click());
2870
+ }
2871
+ // Keyboard accessibility for Enter / Space
2872
+ btnAttach.addEventListener("keydown", (e) => {
2873
+ if (e.key === "Enter" || e.key === " ") {
2874
+ e.preventDefault();
2875
+ fileInput?.click();
2876
+ }
2877
+ });
2878
+ }
2879
+
2880
+ // Image and file paste support
2729
2881
  document.addEventListener("paste", (e) => {
2730
2882
  const items = e.clipboardData?.items;
2731
2883
  if (!items) return;
2732
2884
  const files = [];
2733
2885
  for (let i = 0; i < items.length; i++) {
2734
- if (items[i].type.indexOf("image") !== -1) {
2886
+ if (items[i].kind === "file") {
2735
2887
  const file = items[i].getAsFile();
2736
2888
  if (file) files.push(file);
2737
2889
  }
2738
2890
  }
2739
2891
  if (files.length > 0) {
2740
- handleImageFiles(files);
2892
+ handleIncomingFiles(files);
2741
2893
  const ta = $("#composer");
2742
2894
  if (ta) ta.focus();
2743
2895
  }
@@ -2747,7 +2899,7 @@ async function init() {
2747
2899
  window.addEventListener("dragover", (e) => e.preventDefault(), false);
2748
2900
  window.addEventListener("drop", (e) => e.preventDefault(), false);
2749
2901
 
2750
- // Drag and drop images to composer
2902
+ // Drag and drop images and files to composer
2751
2903
  const composerBox = $("#composerInner") || $(".composer");
2752
2904
  if (composerBox) {
2753
2905
  composerBox.addEventListener("dragover", (e) => {
@@ -2765,7 +2917,7 @@ async function init() {
2765
2917
  e.stopPropagation();
2766
2918
  composerBox.classList.remove("drag-over");
2767
2919
  if (e.dataTransfer?.files) {
2768
- handleImageFiles(e.dataTransfer.files);
2920
+ handleIncomingFiles(e.dataTransfer.files);
2769
2921
  }
2770
2922
  });
2771
2923
  }
package/public/index.html CHANGED
@@ -106,12 +106,12 @@
106
106
  <div class="composer">
107
107
  <div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
108
108
  <div class="composer-inner" id="composerInner">
109
- <input type="file" id="imageFileInput" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg" multiple style="display:none;">
110
- <button class="attach-btn" id="btnAttachImage" type="button" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
109
+ <input type="file" id="imageFileInput" class="sr-only-file-input" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,.heic,.heif,.ico,.avif,text/*,.txt,.md,.json,.js,.ts,.py,.sh,.html,.css,.yaml,.yml,.toml,.xml,.sql,.log,.csv" multiple>
110
+ <label class="attach-btn" id="btnAttachImage" for="imageFileInput" role="button" tabindex="0" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
111
111
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
112
112
  <path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
113
113
  </svg>
114
- </button>
114
+ </label>
115
115
  <textarea id="composer" rows="1" placeholder="给 pi 发消息…"></textarea>
116
116
  <button class="steer-btn" id="steerBtn" title="在 AI 运行过程中插入指导指令" style="display:none;">
117
117
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg>
package/public/style.css CHANGED
@@ -895,6 +895,20 @@ body {
895
895
  .suggestions .chip:hover { border-color: var(--text-dim); color: var(--text); }
896
896
 
897
897
  /* ---------- Composer & Image Upload ---------- */
898
+ .sr-only-file-input {
899
+ position: absolute !important;
900
+ width: 1px !important;
901
+ height: 1px !important;
902
+ padding: 0 !important;
903
+ margin: -1px !important;
904
+ overflow: hidden !important;
905
+ clip: rect(0, 0, 0, 0) !important;
906
+ white-space: nowrap !important;
907
+ border: 0 !important;
908
+ opacity: 0 !important;
909
+ pointer-events: none !important;
910
+ }
911
+
898
912
  .composer {
899
913
  padding: 12px 24px 20px;
900
914
  background: var(--bg);
@@ -917,6 +931,12 @@ body {
917
931
  background: #111;
918
932
  flex-shrink: 0;
919
933
  box-shadow: 0 2px 6px rgba(0,0,0,0.3);
934
+ cursor: pointer;
935
+ transition: transform 0.15s ease, border-color 0.15s ease;
936
+ }
937
+ .image-preview-item:hover {
938
+ transform: translateY(-2px);
939
+ border-color: var(--accent);
920
940
  }
921
941
  .image-preview-item img {
922
942
  width: 100%;
@@ -941,10 +961,12 @@ body {
941
961
  font-size: 12px;
942
962
  line-height: 1;
943
963
  padding: 0;
944
- transition: background 0.15s;
964
+ transition: background 0.15s, transform 0.1s;
965
+ z-index: 2;
945
966
  }
946
967
  .image-preview-remove:hover {
947
968
  background: var(--danger, #ef4444);
969
+ transform: scale(1.1);
948
970
  }
949
971
 
950
972
  .composer-inner {
@@ -972,12 +994,15 @@ body {
972
994
  border: none;
973
995
  color: var(--text-muted);
974
996
  cursor: pointer;
975
- display: flex;
997
+ display: inline-flex;
976
998
  align-items: center;
977
999
  justify-content: center;
978
1000
  border-radius: 50%;
979
1001
  transition: color 0.15s ease, background 0.15s ease, transform 0.1s ease;
980
1002
  flex-shrink: 0;
1003
+ -webkit-tap-highlight-color: transparent;
1004
+ touch-action: manipulation;
1005
+ user-select: none;
981
1006
  }
982
1007
  .composer .attach-btn:hover {
983
1008
  color: var(--text);
@@ -1795,12 +1820,37 @@ body {
1795
1820
  padding-bottom: max(12px, calc(8px + env(safe-area-inset-bottom, 0px)));
1796
1821
  }
1797
1822
 
1823
+ .image-preview-bar {
1824
+ gap: 8px;
1825
+ margin-bottom: 6px;
1826
+ padding: 2px 4px;
1827
+ }
1828
+
1829
+ .image-preview-item {
1830
+ width: 60px;
1831
+ height: 60px;
1832
+ }
1833
+
1834
+ .image-preview-remove {
1835
+ width: 22px;
1836
+ height: 22px;
1837
+ font-size: 14px;
1838
+ top: 2px;
1839
+ right: 2px;
1840
+ background: rgba(0, 0, 0, 0.85);
1841
+ }
1842
+
1798
1843
  .composer-inner {
1799
- padding: 5px 8px 5px 12px;
1844
+ padding: 5px 8px 5px 8px;
1800
1845
  border-radius: 22px;
1801
1846
  gap: 6px;
1802
1847
  }
1803
1848
 
1849
+ .composer .attach-btn {
1850
+ width: 32px;
1851
+ height: 32px;
1852
+ }
1853
+
1804
1854
  .composer textarea {
1805
1855
  padding: 6px 0;
1806
1856
  font-size: 15px;