add file upload feature and table

This commit is contained in:
Zhang Minghan 2023-09-13 20:12:02 +08:00
parent 92e6b1e5d8
commit d9e821f43b
17 changed files with 755 additions and 18 deletions

View File

@ -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)

View File

@ -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>

View File

@ -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
View File

@ -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
View 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;
}
}
}

View File

@ -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%;

View File

@ -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));

View File

@ -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);
}

View 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;

View File

@ -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}

View 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;

View 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 };

View File

@ -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": "由于上下文长度限制,内容已被截取",
},
},
},
};

View File

@ -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))}
/>

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
screenshot/subscription.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB