mirror of
https://github.com/coaidev/coai.git
synced 2025-05-19 21:10:18 +09:00
add file upload feature and table
This commit is contained in:
parent
92e6b1e5d8
commit
d9e821f43b
15
README.md
15
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
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 扩展 | Extension
|
||||

|
||||
|
@ -17,7 +17,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>You need to enable JavaScript to run chat nio.</noscript>
|
||||
<noscript>You need to enable JavaScript to run chatnio.</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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",
|
||||
|
192
app/pnpm-lock.yaml
generated
192
app/pnpm-lock.yaml
generated
@ -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'}
|
||||
|
74
app/src/assets/file.less
Normal file
74
app/src/assets/file.less
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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%;
|
||||
|
@ -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));
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
218
app/src/components/FileProvider.tsx
Normal file
218
app/src/components/FileProvider.tsx
Normal file
@ -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<string>("");
|
||||
const ref = useRef<HTMLLabelElement | null>(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 (
|
||||
<>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<div className={`file-action`}>
|
||||
{
|
||||
active ?
|
||||
<FileCheck className={`h-3.5 w-3.5`} /> :
|
||||
<Plus className={`h-3.5 w-3.5`} />
|
||||
}
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className={`file-dialog flex-dialog`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("file.upload")}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className={`file-wrapper`}>
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("file.type")}</AlertTitle>
|
||||
</Alert>
|
||||
<label className={`drop-window`} htmlFor={id} ref={ref}>
|
||||
{filename ? (
|
||||
<div className={`file-object`}>
|
||||
<File className={`h-4 w-4`} />
|
||||
<p>{filename}</p>
|
||||
<X
|
||||
className={`h-3.5 w-3.5 ml-1 close`}
|
||||
onClick={(e) => {
|
||||
handleChange();
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p>{t("file.drop")}</p>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FileObject
|
||||
id={id}
|
||||
className={className}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FileObject({
|
||||
id,
|
||||
className,
|
||||
onChange,
|
||||
}: FileObjectProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
className={className}
|
||||
onChange={handleChange}
|
||||
multiple={false}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileProvider;
|
@ -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 (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
remarkPlugins={[remarkMath, remarkGfm, remarkFile]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
className={`markdown-body ${className}`}
|
||||
children={children}
|
||||
|
72
app/src/components/plugins/file.tsx
Normal file
72
app/src/components/plugins/file.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import {visit} from 'unist-util-visit';
|
||||
|
||||
/**
|
||||
* file format:
|
||||
* :::file
|
||||
* [[<filename>]]
|
||||
* <file content>
|
||||
* :::
|
||||
*
|
||||
* 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;
|
59
app/src/components/ui/alert.tsx
Normal file
59
app/src/components/ui/alert.tsx
Normal file
@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
@ -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": "由于上下文长度限制,内容已被截取",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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<FileObject>({
|
||||
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() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Input
|
||||
id={`input`}
|
||||
className={`input-box`}
|
||||
ref={target}
|
||||
placeholder={t("chat.placeholder")}
|
||||
onKeyDown={async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") await handleSend(auth, gpt4, web);
|
||||
}}
|
||||
/>
|
||||
<div className={`chat-box`}>
|
||||
<Input
|
||||
id={`input`}
|
||||
className={`input-box`}
|
||||
ref={target}
|
||||
placeholder={t("chat.placeholder")}
|
||||
onKeyDown={async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") await handleSend(auth, gpt4, web);
|
||||
}}
|
||||
/>
|
||||
{
|
||||
auth &&
|
||||
<FileProvider
|
||||
id={`file`}
|
||||
className={`file`}
|
||||
onChange={setFile}
|
||||
maxLength={4000 * 1.25}
|
||||
setClearEvent={setClearEvent}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<Button
|
||||
size={`icon`}
|
||||
variant="outline"
|
||||
@ -311,7 +334,6 @@ function ChatWrapper() {
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
data-v-f9a7276b=""
|
||||
>
|
||||
<path d="m21.426 11.095-17-8A1 1 0 0 0 3.03 4.242l1.212 4.849L12 12l-7.758 2.909-1.212 4.849a.998.998 0 0 0 1.396 1.147l17-8a1 1 0 0 0 0-1.81z"></path>
|
||||
</svg>
|
||||
@ -320,6 +342,7 @@ function ChatWrapper() {
|
||||
<div className={`input-options`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
disabled={!auth}
|
||||
id="enable-gpt4"
|
||||
onCheckedChange={(state: boolean) => dispatch(setGPT4(state))}
|
||||
/>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {FileObject} from "./components/FileProvider.tsx";
|
||||
|
||||
export let mobile =
|
||||
window.innerWidth <= 468 ||
|
||||
@ -140,3 +141,18 @@ export function testNumberInputEvent(e: any): boolean {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
export function formatMessage(file: FileObject, message: string): string {
|
||||
message = message.trim();
|
||||
if (file.name.length > 0 || file.content.length > 0) {
|
||||
return `
|
||||
:::file
|
||||
[[${file.name}]]
|
||||
${file.content}
|
||||
:::
|
||||
|
||||
${message}`;
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
BIN
screenshot/latex.jpg
Normal file
BIN
screenshot/latex.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 104 KiB |
BIN
screenshot/subscription.png
Normal file
BIN
screenshot/subscription.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 89 KiB |
Loading…
Reference in New Issue
Block a user