From d9e821f43bbbaabdd5101eed62dcf8eceb8a2f9c Mon Sep 17 00:00:00 2001 From: Zhang Minghan Date: Wed, 13 Sep 2023 20:12:02 +0800 Subject: [PATCH] add file upload feature and table --- README.md | 15 +- app/index.html | 2 +- app/package.json | 4 +- app/pnpm-lock.yaml | 192 ++++++++++++++++++++++++ app/src/assets/file.less | 74 ++++++++++ app/src/assets/globals.less | 2 +- app/src/assets/home.less | 6 + app/src/assets/markdown/all.less | 31 ++++ app/src/components/FileProvider.tsx | 218 ++++++++++++++++++++++++++++ app/src/components/Markdown.tsx | 17 ++- app/src/components/plugins/file.tsx | 72 +++++++++ app/src/components/ui/alert.tsx | 59 ++++++++ app/src/i18n.ts | 18 +++ app/src/routes/Home.tsx | 47 ++++-- app/src/utils.ts | 16 ++ screenshot/latex.jpg | Bin 0 -> 106275 bytes screenshot/subscription.png | Bin 0 -> 91523 bytes 17 files changed, 755 insertions(+), 18 deletions(-) create mode 100644 app/src/assets/file.less create mode 100644 app/src/components/FileProvider.tsx create mode 100644 app/src/components/plugins/file.tsx create mode 100644 app/src/components/ui/alert.tsx create mode 100644 screenshot/latex.jpg create mode 100644 screenshot/subscription.png diff --git a/README.md b/README.md index 1382a1a..ea87d0e 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ - 🎉 HTTP2 Stream real-time response function 4. 🚀 节流和鉴权体系 - 🚀 Throttling and authentication system -5. 🌈 丰富的聊天功能 - - 🌈 Rich chat features +5. 🌈 丰富的聊天功能 (代码高亮,latex支持,卡片生成,右键菜单) + - 🌈 Rich chat features (code highlight, latex support, card generation, right-click menu) 6. 🎨 多端适配 - 🎨 Multi-device adaptation 7. 📦 缓存系统 @@ -35,11 +35,22 @@ - 🔔 PWA application 11. ⚡ GPT-4 Token 计费系统 - ⚡ GPT-4 Token billing system +12. 📚 逆向工程模型支持 + - 📚 Reverse engineering model support +13. 🌏 国际化支持 + - 🌏 Internationalization support + - 🇨🇳 简体中文 + - 🇺🇸 English +14. 🍎 主题切换 + - 🍎 Theme switching + ## 📚 预览 | Screenshots ![landspace](/screenshot/landspace.png) ![feature](/screenshot/code.png) +![latex](/screenshot/latex.jpg) ![shop](/screenshot/shop.png) +![subscription](/screenshot/subscription.png) ## 扩展 | Extension ![card](https://i.chatnio.net/?message=hi) diff --git a/app/index.html b/app/index.html index 4a64d06..d712ca7 100644 --- a/app/index.html +++ b/app/index.html @@ -17,7 +17,7 @@
- + diff --git a/app/package.json b/app/package.json index 46c2c8d..6a602ac 100644 --- a/app/package.json +++ b/app/package.json @@ -39,10 +39,12 @@ "react-router-dom": "^6.15.0", "react-syntax-highlighter": "^15.5.0", "rehype-katex": "^6.0.3", + "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", "sort-by": "^1.2.0", "tailwind-merge": "^1.14.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@types/node": "^20.5.9", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index e2f761a..456497d 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -89,6 +89,9 @@ dependencies: rehype-katex: specifier: ^6.0.3 version: 6.0.3 + remark-gfm: + specifier: ^3.0.1 + version: 3.0.1 remark-math: specifier: ^5.1.1 version: 5.1.1 @@ -101,6 +104,9 @@ dependencies: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.3.3) + unist-util-visit: + specifier: ^5.0.0 + version: 5.0.0 devDependencies: '@types/node': @@ -2794,6 +2800,10 @@ packages: resolution: {integrity: sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw==} dev: false + /@types/unist@3.0.0: + resolution: {integrity: sha512-MFETx3tbTjE7Uk6vvnWINA/1iJ7LuMdO4fcq8UfF0pRbj01aGLduVvQcRyswuACJdpnHgg8E3rQLhaRdNEJS0w==} + dev: false + /@types/use-sync-external-store@0.0.3: resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} dev: false @@ -3333,6 +3343,10 @@ packages: resolution: {integrity: sha512-/3z+wB4icFt3r0USMwxujAqRvaD/B7rvGTsKhbhSQErVrJvkZCLhgNLJxU8MevahQVH6hCU9FsHdNUFbiwmE7Q==} dev: true + /ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + dev: false + /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -3805,6 +3819,11 @@ packages: engines: {node: '>=10'} dev: true + /escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + dev: false + /eslint-plugin-react-hooks@4.6.0(eslint@8.45.0): resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} engines: {node: '>=10'} @@ -4905,6 +4924,10 @@ packages: dev: true optional: true + /markdown-table@3.0.3: + resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} + dev: false + /match-sorter@6.3.1: resolution: {integrity: sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==} dependencies: @@ -4920,6 +4943,15 @@ packages: unist-util-visit: 4.1.2 dev: false + /mdast-util-find-and-replace@2.2.2: + resolution: {integrity: sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw==} + dependencies: + '@types/mdast': 3.0.12 + escape-string-regexp: 5.0.0 + unist-util-is: 5.2.1 + unist-util-visit-parents: 5.1.3 + dev: false + /mdast-util-from-markdown@1.3.1: resolution: {integrity: sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==} dependencies: @@ -4939,6 +4971,62 @@ packages: - supports-color dev: false + /mdast-util-gfm-autolink-literal@1.0.3: + resolution: {integrity: sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==} + dependencies: + '@types/mdast': 3.0.12 + ccount: 2.0.1 + mdast-util-find-and-replace: 2.2.2 + micromark-util-character: 1.2.0 + dev: false + + /mdast-util-gfm-footnote@1.0.2: + resolution: {integrity: sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ==} + dependencies: + '@types/mdast': 3.0.12 + mdast-util-to-markdown: 1.5.0 + micromark-util-normalize-identifier: 1.1.0 + dev: false + + /mdast-util-gfm-strikethrough@1.0.3: + resolution: {integrity: sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ==} + dependencies: + '@types/mdast': 3.0.12 + mdast-util-to-markdown: 1.5.0 + dev: false + + /mdast-util-gfm-table@1.0.7: + resolution: {integrity: sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg==} + dependencies: + '@types/mdast': 3.0.12 + markdown-table: 3.0.3 + mdast-util-from-markdown: 1.3.1 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + dev: false + + /mdast-util-gfm-task-list-item@1.0.2: + resolution: {integrity: sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ==} + dependencies: + '@types/mdast': 3.0.12 + mdast-util-to-markdown: 1.5.0 + dev: false + + /mdast-util-gfm@2.0.2: + resolution: {integrity: sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==} + dependencies: + mdast-util-from-markdown: 1.3.1 + mdast-util-gfm-autolink-literal: 1.0.3 + mdast-util-gfm-footnote: 1.0.2 + mdast-util-gfm-strikethrough: 1.0.3 + mdast-util-gfm-table: 1.0.7 + mdast-util-gfm-task-list-item: 1.0.2 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + dev: false + /mdast-util-math@2.0.2: resolution: {integrity: sha512-8gmkKVp9v6+Tgjtq6SYx9kGPpTf6FVYRa53/DLh479aldR9AyP48qeVOgNZ5X7QUK7nOy4yw7vg6mbiGcs9jWQ==} dependencies: @@ -5015,6 +5103,78 @@ packages: uvu: 0.5.6 dev: false + /micromark-extension-gfm-autolink-literal@1.0.5: + resolution: {integrity: sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg==} + dependencies: + micromark-util-character: 1.2.0 + micromark-util-sanitize-uri: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + dev: false + + /micromark-extension-gfm-footnote@1.1.2: + resolution: {integrity: sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q==} + dependencies: + micromark-core-commonmark: 1.1.0 + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-normalize-identifier: 1.1.0 + micromark-util-sanitize-uri: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + dev: false + + /micromark-extension-gfm-strikethrough@1.0.7: + resolution: {integrity: sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw==} + dependencies: + micromark-util-chunked: 1.1.0 + micromark-util-classify-character: 1.1.0 + micromark-util-resolve-all: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + dev: false + + /micromark-extension-gfm-table@1.0.7: + resolution: {integrity: sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw==} + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + dev: false + + /micromark-extension-gfm-tagfilter@1.0.2: + resolution: {integrity: sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g==} + dependencies: + micromark-util-types: 1.1.0 + dev: false + + /micromark-extension-gfm-task-list-item@1.0.5: + resolution: {integrity: sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ==} + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + dev: false + + /micromark-extension-gfm@2.0.3: + resolution: {integrity: sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ==} + dependencies: + micromark-extension-gfm-autolink-literal: 1.0.5 + micromark-extension-gfm-footnote: 1.1.2 + micromark-extension-gfm-strikethrough: 1.0.7 + micromark-extension-gfm-table: 1.0.7 + micromark-extension-gfm-tagfilter: 1.0.2 + micromark-extension-gfm-task-list-item: 1.0.5 + micromark-util-combine-extensions: 1.1.0 + micromark-util-types: 1.1.0 + dev: false + /micromark-extension-math@2.1.2: resolution: {integrity: sha512-es0CcOV89VNS9wFmyn+wyFTKweXGW4CEvdaAca6SWRWPyYCbBisnjaHLjWO4Nszuiud84jCpkHsqAJoa768Pvg==} dependencies: @@ -5878,6 +6038,17 @@ packages: engines: {node: '>= 0.10'} dev: true + /remark-gfm@3.0.1: + resolution: {integrity: sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==} + dependencies: + '@types/mdast': 3.0.12 + mdast-util-gfm: 2.0.2 + micromark-extension-gfm: 2.0.3 + unified: 10.1.2 + transitivePeerDependencies: + - supports-color + dev: false + /remark-math@5.1.1: resolution: {integrity: sha512-cE5T2R/xLVtfFI4cCePtiRn+e6jKMtFDR3P8V3qpv8wpKjwvHoBA4eJzvX+nVrnlNy0911bdGmuspCSwetfYHw==} dependencies: @@ -6521,6 +6692,12 @@ packages: '@types/unist': 2.0.8 dev: false + /unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + dependencies: + '@types/unist': 3.0.0 + dev: false + /unist-util-position@4.0.4: resolution: {integrity: sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==} dependencies: @@ -6547,6 +6724,13 @@ packages: unist-util-is: 5.2.1 dev: false + /unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + dependencies: + '@types/unist': 3.0.0 + unist-util-is: 6.0.0 + dev: false + /unist-util-visit@4.1.2: resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} dependencies: @@ -6555,6 +6739,14 @@ packages: unist-util-visit-parents: 5.1.3 dev: false + /unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + dependencies: + '@types/unist': 3.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + dev: false + /universalify@2.0.0: resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} engines: {node: '>= 10.0.0'} diff --git a/app/src/assets/file.less b/app/src/assets/file.less new file mode 100644 index 0000000..d5db50e --- /dev/null +++ b/app/src/assets/file.less @@ -0,0 +1,74 @@ +.file-action { + position: absolute; + top: 50%; + left: 6px; + transform: translateY(-50%); + background: hsl(var(--input)) !important; + padding: 6px; + border-radius: 50%; + cursor: pointer; + transition: 0.1s; + outline: 0; + + &:hover { + background: hsl(var(--border-hover)) !important; + } +} + +.file-dialog { + max-width: min(90vw, 720px) !important; +} + +.file-wrapper { + margin-top: 24px !important; + padding: 8px 0; +} + +.drop-window { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + min-height: 200px; + height: 20vh; + border: 2px dashed hsl(var(--border)); + border-radius: var(--radius); + transition: 0.25s; + margin: 12px 0; + cursor: pointer; + color: hsl(var(--text-secondary)); +} + +.drop-window:hover { + border: 2px dashed hsl(var(--border-hover)); + color: hsl(var(--text)); +} + +.file-object { + position: relative; + display: flex; + flex-direction: row; + gap: 4px; + align-items: center; + padding: 10px 12px; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + transition: 0.25s; + color: hsl(var(--text-secondary)) !important; + cursor: pointer; + + &:hover { + border: 1px solid hsl(var(--border-hover)); + color: hsl(var(--text)) !important; + } + + .close { + color: hsl(var(--text-secondary)) !important; + transition: .1s; + + &:hover { + color: hsl(var(--text)) !important; + } + } +} diff --git a/app/src/assets/globals.less b/app/src/assets/globals.less index f050305..af0e37e 100644 --- a/app/src/assets/globals.less +++ b/app/src/assets/globals.less @@ -33,7 +33,7 @@ --border: 37 26% 83%; --border-hover: 37 26% 78%; --input: 37 26% 90%; - --input-unread: 37 26% 95%; + --input-unread: 37 26% 70%; --ring: 222.2 84% 4.9%; --text: 0 0% 0%; --text-secondary: 0 0% 20%; diff --git a/app/src/assets/home.less b/app/src/assets/home.less index 246818a..5ae31a2 100644 --- a/app/src/assets/home.less +++ b/app/src/assets/home.less @@ -254,7 +254,13 @@ gap: 4px; height: min-content; + .chat-box { + position: relative; + flex-grow: 1; + } + .input-box { + width: 100%; text-align: center; color: hsl(var(--text)); diff --git a/app/src/assets/markdown/all.less b/app/src/assets/markdown/all.less index 73ba1ce..7c25f93 100644 --- a/app/src/assets/markdown/all.less +++ b/app/src/assets/markdown/all.less @@ -1,3 +1,34 @@ @import "highlight.less"; @import "style.less"; @import "theme.less"; + +.file-instance { + cursor: pointer; + display: flex; + flex-direction: row; + align-items: center; + margin: 2px 0; + padding: 6px 12px; + border: 1px solid hsl(var(--border-hover)); + background: hsl(var(--background-container)); + border-radius: var(--radius); + + &:before { + content: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1maWxlIj48cGF0aCBkPSJNMTQuNSAySDZhMiAyIDAgMCAwLTIgMnYxNmEyIDIgMCAwIDAgMiAyaDEyYTIgMiAwIDAgMCAyLTJWNy41TDE0LjUgMnoiLz48cG9seWxpbmUgcG9pbnRzPSIxNCAyIDE0IDggMjAgOCIvPjwvc3ZnPg=="); + display: block; + width: 24px; + height: 20px; + scale: 0.6; + color: hsl(var(--text)); + } + + &:after { + content: attr(file); + display: block; + color: hsl(var(--text)); + } +} + +.dark .file-instance:before { + filter: invert(1); +} diff --git a/app/src/components/FileProvider.tsx b/app/src/components/FileProvider.tsx new file mode 100644 index 0000000..5e5a970 --- /dev/null +++ b/app/src/components/FileProvider.tsx @@ -0,0 +1,218 @@ +import React, {useEffect, useRef, useState} from "react"; +import {AlertCircle, File, FileCheck, Plus, X} from "lucide-react"; +import "../assets/file.less"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; +import { useTranslation } from "react-i18next"; +import { Alert, AlertTitle } from "./ui/alert.tsx"; +import { useToast } from "./ui/use-toast.ts"; + +export type FileObject = { + name: string; + content: string; +} + +type FileProviderProps = { + id?: string; + className?: string; + maxLength?: number; + onChange?: (data: FileObject) => void; + setClearEvent?: (event: () => void) => void; +}; + +type FileObjectProps = { + id?: string; + className?: string; + onChange?: (filename: string, data: string) => void; +}; + +function FileProvider({ + id, + className, + onChange, + maxLength, + setClearEvent, +}: FileProviderProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [active, setActive] = useState(false); + const [filename, setFilename] = useState(""); + const ref = useRef(null); + + useEffect(() => { + setClearEvent && setClearEvent(() => clear); + + return () => { + setClearEvent && setClearEvent(() => {}); + } + }, [setClearEvent]); + + useEffect(() => { + if (!ref.current) return; + const target = ref.current as HTMLLabelElement; + + target.addEventListener("dragover", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + target.addEventListener("drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + const file = e.dataTransfer?.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const data = e.target?.result as string; + if (!/^[\x00-\x7F]*$/.test(data)) { + toast({ + title: t("file.parse-error"), + description: t("file.parse-error-prompt"), + }); + handleChange(); + } else { + handleChange(e.target?.result as string); + } + }; + reader.readAsText(file); + } else { + handleChange(); + } + }); + target.addEventListener("dragleave", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + return () => { + target.removeEventListener("dragover", () => {}); + target.removeEventListener("drop", () => {}); + } + }, [ref]); + + function clear() { + setFilename(""); + setActive(false); + onChange?.({ name: "", content: "" }); + } + + function handleChange(name?: string, data?: string) { + name = name || ""; + data = data || ""; + + if (maxLength && data.length > maxLength) { + data = data.slice(0, maxLength); + toast({ + title: t("file.max-length"), + description: t("file.max-length-prompt"), + }); + } + setActive(data !== ""); + if (data === "") { + setFilename(""); + onChange?.({ name: "", content: "" }); + } else { + setFilename(name); + onChange?.({ name: name, content: data }); + } + } + + return ( + <> + + +
+ { + active ? + : + + } +
+
+ + + {t("file.upload")} + +
+ + + {t("file.type")} + + +
+
+
+
+
+ + + ); +} + +function FileObject({ + id, + className, + onChange, +}: FileObjectProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + + const handleChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const data = e.target?.result as string; + if (!/^[\x00-\x7F]*$/.test(data)) { + toast({ + title: t("file.parse-error"), + description: t("file.parse-error-prompt"), + }); + onChange?.(file.name, ""); + } else { + onChange?.(file.name, e.target?.result as string); + } + }; + reader.readAsText(file); + } else { + onChange?.("", ""); + } + }; + return ( + + ); +} + +export default FileProvider; diff --git a/app/src/components/Markdown.tsx b/app/src/components/Markdown.tsx index 3c442b0..7d04963 100644 --- a/app/src/components/Markdown.tsx +++ b/app/src/components/Markdown.tsx @@ -1,9 +1,13 @@ import { LightAsync as SyntaxHighlighter } from "react-syntax-highlighter"; import { atomOneDark as style } from "react-syntax-highlighter/dist/esm/styles/hljs"; import ReactMarkdown from "react-markdown"; +import remarkGfm from 'remark-gfm'; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; +import remarkFile from "./plugins/file.tsx"; import "../assets/markdown/all.less"; +import {useEffect} from "react"; +import {saveAsFile} from "../utils.ts"; type MarkdownProps = { children: string; @@ -11,9 +15,20 @@ type MarkdownProps = { }; function Markdown({ children, className }: MarkdownProps) { + useEffect(() => { + document.querySelectorAll(".file-instance").forEach((e) => { + e.addEventListener("click", () => { + const filename = e.getAttribute("file") as string; + const data = e.getAttribute("content") as string; + if (data) { + saveAsFile(filename, data); + } + }); + }); + }, [children]); return ( ]] + * + * ::: + * + * note that file content may apart into multiple paragraph. + */ + +function fileMarkdownPlugin() { + return (tree: any) => { + const cache = { + name: "", + content: "", + last: false, + cursor: 0, + } + + function parse(data: string, index: number, parent: any) { + if (data.startsWith(':::file')) { + cache.name = ""; + cache.content = ""; + cache.last = true; + cache.cursor = index; + + const part = data.slice(7); + if (part.length > 0) { + parse(part.trimStart(), index, parent); + } + } else if (data.startsWith(':::')) { + cache.last = false; + parent.children.splice(cache.cursor, index - cache.cursor + 1, { + type: 'div', + data: { + hName: 'div', + hProperties: { + className: 'file-instance', + file: cache.name, + content: cache.content, + }, + } + }); + cache.name = ""; + cache.content = ""; + } else if (cache.last) { + if (cache.name.length === 0 && data.startsWith('[[')) { + // may contain content + const end = data.indexOf(']]'); + if (end !== -1) { + cache.name = data.slice(2, end); + parse(data.slice(end + 2).trimStart(), index, parent); + } else { + cache.name = data.slice(2); + } + } else { + cache.content += data; + } + } + } + + visit(tree, 'paragraph', (node, index, parent) => { + for (const child of node.children) { + parse((child.value || ""), index as number, parent); + } + }); + }; +} + +export default fileMarkdownPlugin; diff --git a/app/src/components/ui/alert.tsx b/app/src/components/ui/alert.tsx new file mode 100644 index 0000000..21134dd --- /dev/null +++ b/app/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "./lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/app/src/i18n.ts b/app/src/i18n.ts index 19877ea..9b3e06c 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -131,6 +131,15 @@ const resources = { cancel: "Cancel", confirm: "Confirm", percent: "{{cent}}0%", + file: { + upload: "Upload File", + type: "Currently only text files are supported for upload", + drop: "Drag and drop files here or click to upload", + "parse-error": "Parse Error", + "parse-error-prompt": "Parse error, currently only text files are supported", + "max-length": "Content too long", + "max-length-prompt": "The content has been truncated due to the context length limit", + } }, }, cn: { @@ -251,6 +260,15 @@ const resources = { cancel: "取消", confirm: "确认", percent: "{{cent}}折", + file: { + upload: "上传文件", + type: "当前仅支持上传文本类型文件", + drop: "拖拽文件到此处或点击上传", + "parse-error": "解析失败", + "parse-error-prompt": "解析失败,当前仅支持文本类型文件", + "max-length": "内容过长", + "max-length-prompt": "由于上下文长度限制,内容已被截取", + }, }, }, }; diff --git a/app/src/routes/Home.tsx b/app/src/routes/Home.tsx index 2a151b6..74f0887 100644 --- a/app/src/routes/Home.tsx +++ b/app/src/routes/Home.tsx @@ -30,7 +30,7 @@ import { updateConversationList, } from "../conversation/history.ts"; import React, { useEffect, useRef, useState } from "react"; -import { mobile, useAnimation, useEffectAsync } from "../utils.ts"; +import {formatMessage, mobile, useAnimation, useEffectAsync} from "../utils.ts"; import { useToast } from "../components/ui/use-toast.ts"; import { ConversationInstance, Message } from "../conversation/types.ts"; import { @@ -57,6 +57,7 @@ import { manager } from "../conversation/manager.ts"; import { useTranslation } from "react-i18next"; import MessageSegment from "../components/Message.tsx"; import { setMenu } from "../store/menu.ts"; +import FileProvider, {FileObject} from "../components/FileProvider.tsx"; function SideBar() { const { t } = useTranslation(); @@ -241,6 +242,11 @@ function ChatInterface() { function ChatWrapper() { const { t } = useTranslation(); + const [ file, setFile ] = useState({ + name: "", + content: "", + }); + const [ clearEvent, setClearEvent ] = useState<() => void>(() => {}); const dispatch = useDispatch(); const auth = useSelector(selectAuthenticated); const gpt4 = useSelector(selectGPT4); @@ -248,13 +254,18 @@ function ChatWrapper() { const target = useRef(null); manager.setDispatch(dispatch); + function clearFile() { + clearEvent?.(); + } + async function handleSend(auth: boolean, gpt4: boolean, web: boolean) { // because of the function wrapper, we need to update the selector state using props. if (!target.current) return; const el = target.current as HTMLInputElement; - const message: string = el.value.trim(); + const message: string = formatMessage(file, el.value); if (message.length > 0) { if (await manager.send(t, auth, { message, web, gpt4 })) { + clearFile(); el.value = ""; } } @@ -290,15 +301,27 @@ function ChatWrapper() { - ) => { - if (e.key === "Enter") await handleSend(auth, gpt4, web); - }} - /> +
+ ) => { + if (e.key === "Enter") await handleSend(auth, gpt4, web); + }} + /> + { + auth && + + } +