Compare commits

..

18 Commits
v3.6 ... v3.7.1

Author SHA1 Message Date
archer
2843178ede feat: openapi cors 2023-05-19 12:01:59 +08:00
archer
bb312441c6 fix: share prompts 2023-05-19 11:17:20 +08:00
archer
d07e5b8501 price 2023-05-19 10:31:25 +08:00
archer
246ee973ec feat: share unlogin.perf: link format and model ui 2023-05-19 10:26:30 +08:00
archer
a62a9c4067 perf: stream response 2023-05-19 00:00:56 +08:00
archer
7408db9cf6 docs 2023-05-18 22:31:18 +08:00
archer
5d4dd4a18c fix: ui bug 2023-05-18 20:54:01 +08:00
archer
5bf95bd846 feat: model related kb 2023-05-17 22:24:36 +08:00
archer
a79429fdcd feat: kb crud 2023-05-17 19:30:43 +08:00
archer
021add2af4 fix: safari reg error 2023-05-16 14:27:10 +08:00
archer
371e0e36c6 perf: error show 2023-05-15 22:33:37 +08:00
archer
e7d3a8e2e1 perf: http recognition and input textarea 2023-05-15 22:33:37 +08:00
archer
32a8d68c6c feat: docs and git 2023-05-15 22:33:36 +08:00
archer
06ab718e6e fix: share login. 2023-05-15 22:33:35 +08:00
archer
1d74095739 temp 2023-05-15 22:33:35 +08:00
archer
ca99837dab fix: ui;perf: docs 2023-05-15 22:33:34 +08:00
archer
d31bdf0ee0 feat: share chat page 2023-05-15 22:33:33 +08:00
ShengYan, Zhang
d3e7923040 fix: correct sql script. 2023-05-15 20:31:28 +08:00
100 changed files with 2203 additions and 1916 deletions

View File

@@ -21,7 +21,8 @@ Fast GPT 允许你使用自己的 openai API KEY 来快速的调用 openai 接
## 🚀 私有化部署
[docker-compose 部署教程](docs/deploy/docker.md)
- [docker-compose 部署教程](docs/deploy/docker.md)
- [由社区贡献的宝塔部署和本地运行教程](https://space.bilibili.com/431177525/channel/collectiondetail?sid=1370663)
## :point_right: RoadMap
@@ -29,8 +30,8 @@ Fast GPT 允许你使用自己的 openai API KEY 来快速的调用 openai 接
## 🏘️ 交流群
wx: fastgpt123
![Demo](docs/imgs/wx300.jpg?raw=true 'wx')
添加 wx 进入:
![Demo](https://otnvvf-imgs.oss.laf.run/wx300.png?raw=true 'wx')
## 👀 其他

View File

@@ -35,185 +35,10 @@ docker-compose -v
### 2. 创建 3 个初始化文件
fastgpt 文件夹。分别为fastgpt/docker-compose.yaml, fastgpt/pg/init.sql, fastgpt/nginx/nginx.conf
手动创建或者直接把 fastgpt 文件夹复制过去。
**/root/fastgpt/pg/init.sql PG 数据库初始化**
```sql
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE EXTENSION vector;
-- init table
CREATE TABLE modelData (
id BIGSERIAL PRIMARY KEY,
vector VECTOR(1536),
status VARCHAR(50) NOT NULL,
user_id VARCHAR(50) NOT NULL,
model_id VARCHAR(50) NOT NULL,
q TEXT NOT NULL,
a TEXT NOT NULL
);
-- create index
CREATE INDEX modelData_status_index ON modelData USING HASH (status);
CREATE INDEX modelData_userId_index ON modelData USING HASH (user_id);
CREATE INDEX modelData_modelId_index ON modelData USING HASH (model_id);
EOSQL
```
**/root/fastgpt/nginx/nginx.conf Nginx 配置**
```conf
user nginx;
worker_processes auto;
worker_rlimit_nofile 51200;
events {
worker_connections 1024;
}
http {
resolver 8.8.8.8;
proxy_ssl_server_name on;
access_log off;
server_names_hash_bucket_size 512;
client_header_buffer_size 64k;
large_client_header_buffers 4 64k;
client_max_body_size 50M;
proxy_connect_timeout 240s;
proxy_read_timeout 240s;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
gzip on;
gzip_min_length 1k;
gzip_buffers 4 8k;
gzip_http_version 1.1;
gzip_comp_level 6;
gzip_vary on;
gzip_types text/plain application/x-javascript text/css application/javascript application/json application/xml;
gzip_disable "MSIE [1-6]\.";
open_file_cache max=1000 inactive=1d;
open_file_cache_valid 30s;
open_file_cache_min_uses 8;
open_file_cache_errors off;
server {
listen 443 ssl;
# 改成自己的域名和证书
server_name docgpt.ahapocket.cn;
ssl_certificate /ssl/docgpt.pem;
ssl_certificate_key /ssl/docgpt.key;
ssl_session_timeout 5m;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
server {
listen 80;
server_name docgpt.ahapocket.cn;
rewrite ^(.*) https://$server_name$1 permanent;
}
}
```
**/root/fastgpt/docker-compose.yml 核心部署文件**
环境变量内容和开发时的环境变量基本相同,除了数据库的地址。
```yml
version: '3.3'
services:
pg:
image: ankane/pgvector:v0.4.1
container_name: pg
restart: always
ports:
- 8100:5432
environment:
# 这里的配置只有首次运行生效。修改后,重启镜像是不会生效的。需要把持久化数据删除再重启,才有效果
- POSTGRES_USER=fastgpt
- POSTGRES_PASSWORD=1234
- POSTGRES_DB=fastgpt
volumes:
# 刚创建的文件
- /root/fastgpt/pg/init.sql:/docker-entrypoint-initdb.d/init.sh
- /root/fastgpt/pg/data:/var/lib/postgresql/data
- /etc/localtime:/etc/localtime:ro
mongodb:
image: mongo:6.0.4
container_name: mongo
restart: always
ports:
- 27017:27017
environment:
# 这里的配置只有首次运行生效。修改后,重启镜像是不会生效的。需要把持久化数据删除再重启,才有效果
- MONGO_INITDB_ROOT_USERNAME=username
- MONGO_INITDB_ROOT_PASSWORD=password
volumes:
- /root/fastgpt/mongo/data:/data/db
- /root/fastgpt/mongo/logs:/var/log/mongodb
- /etc/localtime:/etc/localtime:ro
fastgpt:
image: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt:latest
network_mode: host
restart: always
container_name: fastgpt
environment:
# proxy可选
- AXIOS_PROXY_HOST=127.0.0.1
- AXIOS_PROXY_PORT=7890
# 是否开启队列任务。 1-开启0-关闭(请求 parentUrl 去执行任务,单机时直接填1
- queueTask=1
- parentUrl=https://hostname/api/openapi/startEvents
# 发送邮箱验证码配置。用的是QQ邮箱。参考 nodeMail 获取MAILE_CODE自行百度。
- MY_MAIL=xxxx@qq.com
- MAILE_CODE=xxxx
# 阿里短信服务(邮箱和短信至少二选一)
- aliAccessKeyId=xxxx
- aliAccessKeySecret=xxxx
- aliSignName=xxxxx
- aliTemplateCode=SMS_xxxx
# token加密凭证随便填作为登录凭证
- TOKEN_KEY=xxxx
# 和上方mongo镜像的username,password对应
- MONGODB_URI=mongodb://username:password@0.0.0.0:27017/?authSource=admin
- MONGODB_NAME=fastgpt
- PG_HOST=0.0.0.0
- PG_PORT=8100
# 和上方PG镜像对应.
- PG_USER=fastgpt # POSTGRES_USER
- PG_PASSWORD=1234 # POSTGRES_PASSWORD
- PG_DB_NAME=fastgpt # POSTGRES_DB
# openai
- OPENAIKEY=sk-xxxxx
- GPT4KEY=sk-xxx
- OPENAI_BASE_URL=https://api.openai.com/v1
- OPENAI_BASE_URL_AUTH=可选的安全凭证
# claude
- CLAUDE_BASE_URL=calude模型请求地址
- CLAUDE_KEY=CLAUDE_KEY
nginx:
image: nginx:alpine3.17
container_name: nginx
restart: always
network_mode: host
volumes:
# 刚创建的文件
- /root/fastgpt/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- /root/fastgpt/nginx/logs:/var/log/nginx
# https证书没有的话不填对应的nginx.conf也要修改
- /root/fastgpt/nginx/ssl/docgpt.key:/ssl/docgpt.key
- /root/fastgpt/nginx/ssl/docgpt.pem:/ssl/docgpt.pem
```
### 3. 运行 docker-compose
下面是一个辅助脚本,也可以直接 docker-compose up -d

View File

@@ -1,7 +1,7 @@
version: '3.3'
services:
pg:
image: ankane/pgvector:v0.4.1
image: ankane/pgvector:v0.4.2
container_name: pg
restart: always
ports:

View File

@@ -8,12 +8,14 @@ CREATE TABLE modelData (
vector VECTOR(1536),
status VARCHAR(50) NOT NULL,
user_id VARCHAR(50) NOT NULL,
model_id VARCHAR(50) NOT NULL,
model_id VARCHAR(50),
kb_id VARCHAR(50),
q TEXT NOT NULL,
a TEXT NOT NULL
);
-- create index
CREATE INDEX modelData_status_index ON modelData USING HASH (status);
CREATE INDEX modelData_userId_index ON modelData USING HASH (user_id);
CREATE INDEX modelData_modelId_index ON modelData USING HASH (model_id);
CREATE INDEX modelData_userId_index ON modelData USING HASH (model_id);
CREATE INDEX modelData_kbId_index ON modelData USING HASH (kb_id);
EOSQL

View File

@@ -67,7 +67,7 @@ docker run --name mongo -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=username -e
**3、部署 pgsql**
```
docker run -it --name pg -e "POSTGRES_PASSWORD=xxx" -e POSTGRES_USER=xxx -p 8100:5432 -v ~/fastgpt/pg/data:/var/lib/postgresql/data -d octoberlan/pgvector:v0.4.1
docker run -it --name pg -e "POSTGRES_DB=fastgpt" -e "POSTGRES_PASSWORD=xxx" -e POSTGRES_USER=xxx -p 8100:5432 -v ~/fastgpt/pg/data:/var/lib/postgresql/data -d octoberlan/pgvector:v0.4.1
```
进 pgsql 容器运行
@@ -88,8 +88,8 @@ CREATE TABLE modelData (
);
-- create index
CREATE INDEX modelData_status_index ON modelData (status);
CREATE INDEX modelData_modelId_index ON modelData (modelId);
CREATE INDEX modelData_userId_index ON modelData (userId);
CREATE INDEX modelData_modelId_index ON modelData (model_id);
CREATE INDEX modelData_userId_index ON modelData (user_id);
EOSQL
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -39,6 +39,7 @@
"mongoose": "^6.10.0",
"nanoid": "^4.0.1",
"next": "13.1.6",
"nextjs-cors": "^2.1.2",
"nodemailer": "^6.9.1",
"nprogress": "^0.2.0",
"openai": "^3.2.1",

61
pnpm-lock.yaml generated
View File

@@ -46,6 +46,7 @@ specifiers:
mongoose: ^6.10.0
nanoid: ^4.0.1
next: 13.1.6
nextjs-cors: ^2.1.2
nodemailer: ^6.9.1
nprogress: ^0.2.0
openai: ^3.2.1
@@ -97,6 +98,7 @@ dependencies:
mongoose: registry.npmmirror.com/mongoose/6.10.0
nanoid: registry.npmmirror.com/nanoid/4.0.1
next: registry.npmmirror.com/next/13.1.6_wiv434v7erz4aedd5whhdwmpv4
nextjs-cors: 2.1.2_next@13.1.6
nodemailer: registry.npmmirror.com/nodemailer/6.9.1
nprogress: registry.npmmirror.com/nprogress/0.2.0
openai: registry.npmmirror.com/openai/3.2.1
@@ -294,11 +296,29 @@ packages:
resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==}
dev: true
/@types/hast/2.3.4:
resolution: {integrity: sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==}
dependencies:
'@types/unist': 2.0.6
dev: false
/@types/unist/2.0.6:
resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==}
dev: false
/cookie/0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
dev: false
/cors/2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
dependencies:
object-assign: 4.1.1
vary: 1.1.2
dev: false
/fsevents/2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -309,9 +329,24 @@ packages:
/graceful-fs/4.2.10:
resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
requiresBuild: true
dev: false
optional: true
/nextjs-cors/2.1.2_next@13.1.6:
resolution: {integrity: sha512-2yOVivaaf2ILe4f/qY32hnj3oC77VCOsUQJQfhVMGsXE/YMEWUY2zy78sH9FKUCM7eG42/l3pDofIzMD781XGA==}
peerDependencies:
next: ^8.1.1-canary.54 || ^9.0.0 || ^10.0.0-0 || ^11.0.0 || ^12.0.0 || ^13.0.0
dependencies:
cors: 2.8.5
next: registry.npmmirror.com/next/13.1.6_wiv434v7erz4aedd5whhdwmpv4
dev: false
/object-assign/4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
dev: false
/saslprep/1.0.3:
resolution: {integrity: sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==}
engines: {node: '>=6'}
@@ -324,9 +359,15 @@ packages:
/source-map/0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
requiresBuild: true
dev: false
optional: true
/vary/1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
dev: false
registry.npmmirror.com/@alicloud/credentials/2.2.6:
resolution: {integrity: sha512-jG+msY77dHmAF3x+8VTy7fEgORyXLHmDci8t92HeipBdCHsPptDegA++GEwKgR7f6G4wvafYt+aqMZ1iligdrQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/@alicloud/credentials/-/credentials-2.2.6.tgz}
name: '@alicloud/credentials'
@@ -7742,7 +7783,7 @@ packages:
name: hast-util-from-parse5
version: 7.1.2
dependencies:
'@types/hast': registry.npmmirror.com/@types/hast/2.3.4
'@types/hast': 2.3.4
'@types/unist': registry.npmmirror.com/@types/unist/2.0.6
hastscript: registry.npmmirror.com/hastscript/7.2.0
property-information: registry.npmmirror.com/property-information/6.2.0
@@ -7756,7 +7797,7 @@ packages:
name: hast-util-is-element
version: 2.1.3
dependencies:
'@types/hast': registry.npmmirror.com/@types/hast/2.3.4
'@types/hast': 2.3.4
'@types/unist': registry.npmmirror.com/@types/unist/2.0.6
dev: false
@@ -7771,7 +7812,7 @@ packages:
name: hast-util-parse-selector
version: 3.1.1
dependencies:
'@types/hast': registry.npmmirror.com/@types/hast/2.3.4
'@types/hast': 2.3.4
dev: false
registry.npmmirror.com/hast-util-to-text/3.1.2:
@@ -7796,7 +7837,7 @@ packages:
name: hastscript
version: 6.0.0
dependencies:
'@types/hast': registry.npmmirror.com/@types/hast/2.3.4
'@types/hast': 2.3.4
comma-separated-tokens: registry.npmmirror.com/comma-separated-tokens/1.0.8
hast-util-parse-selector: registry.npmmirror.com/hast-util-parse-selector/2.2.5
property-information: registry.npmmirror.com/property-information/5.6.0
@@ -7808,7 +7849,7 @@ packages:
name: hastscript
version: 7.2.0
dependencies:
'@types/hast': registry.npmmirror.com/@types/hast/2.3.4
'@types/hast': 2.3.4
comma-separated-tokens: registry.npmmirror.com/comma-separated-tokens/2.0.3
hast-util-parse-selector: registry.npmmirror.com/hast-util-parse-selector/3.1.1
property-information: registry.npmmirror.com/property-information/6.2.0
@@ -8762,7 +8803,7 @@ packages:
version: 5.1.2
dependencies:
'@types/mdast': registry.npmmirror.com/@types/mdast/3.0.10
'@types/unist': registry.npmmirror.com/@types/unist/2.0.6
'@types/unist': 2.0.6
unist-util-visit: registry.npmmirror.com/unist-util-visit/4.1.2
dev: false
@@ -8890,7 +8931,7 @@ packages:
name: mdast-util-to-hast
version: 12.3.0
dependencies:
'@types/hast': registry.npmmirror.com/@types/hast/2.3.4
'@types/hast': 2.3.4
'@types/mdast': registry.npmmirror.com/@types/mdast/3.0.10
mdast-util-definitions: registry.npmmirror.com/mdast-util-definitions/5.1.2
micromark-util-sanitize-uri: registry.npmmirror.com/micromark-util-sanitize-uri/1.1.0
@@ -9441,7 +9482,7 @@ packages:
version: 2.7.0
dependencies:
any-promise: registry.npmmirror.com/any-promise/1.3.0
object-assign: registry.npmmirror.com/object-assign/4.1.1
object-assign: 4.1.1
thenify-all: registry.npmmirror.com/thenify-all/1.6.0
dev: false
@@ -11624,7 +11665,7 @@ packages:
name: unist-util-position
version: 4.0.4
dependencies:
'@types/unist': registry.npmmirror.com/@types/unist/2.0.6
'@types/unist': 2.0.6
dev: false
registry.npmmirror.com/unist-util-remove-position/4.0.2:
@@ -11817,7 +11858,7 @@ packages:
name: vfile-location
version: 4.1.0
dependencies:
'@types/unist': registry.npmmirror.com/@types/unist/2.0.6
'@types/unist': 2.0.6
vfile: registry.npmmirror.com/vfile/5.3.7
dev: false

View File

@@ -1,16 +1,27 @@
### 常见问题
**请求次数太多了**
一般是因为自己的 openai 账号异常。请先检查自己的账号是否正常使用。
**内容长度**
chatgpt 上下文最长 4096 tokens, 会自动截取上下文,超过 4096 部分会被遗忘。
**Git 地址**
[项目地址,完全开源,随便用。](https://github.com/c121914yu/FastGPT)
**问题文档**
[先看文档,再提问](https://kjqvjse66l.feishu.cn/docx/HtrgdT0pkonP4kxGx8qcu6XDnGh)
**删除和复制**
电脑端:聊天内容右侧有复制和删除的图标。
移动端:点击对话头像,可以选择复制或删除该条内容。
**代理出错**
服务器代理不稳定,可以过一会儿再尝试。 或者可以访问国外服务器: [FastGpt](https://fastgpt.run/)
**价格表**
如果使用了自己的 Api Key不会计费。可以在账号页看到详细账单。
| 计费项 | 价格: 元/ 1K tokens包含上下文|
| --- | --- |
| claude - 对话 | 免费 |
| 知识库 - 索引 | 免费 |
| chatgpt - 对话 | 0.025 |
| gpt4 - 对话 | 0.5 |
| 文件拆分 | 0.025 |
**其他问题**
请 WX 联系: fastgpt123
| 交流群 | 小助手 |
| ----------------------- | -------------------- |
| ![](/imgs/wxqun300.jpg) | ![](/imgs/wx300.jpg) |
| ![](https://otnvvf-imgs.oss.laf.run/wxqun300.jpg) | ![](https://otnvvf-imgs.oss.laf.run/wx300.png) |

View File

@@ -1,7 +1,9 @@
接受一个 csv 文件,表格头包含 question 和 answer。question 代表问题answer 代表答案。
导入前会进行去重,如果问题和答案完全相同,则不会被导入,所以最终导入的内容可能会比文件的内容少。但是,对于带有换行的内容,目前无法去重。
**请保证 csv 文件为 utf-8 编码**
| question | answer |
| --- | --- |
| 什么是 laf | laf 是一个云函数开发平台…… |
导入前会进行去重,如果问题和答案完全相同,则不会被导入,所以最终导入的内容可能会比文件的内容少。但是,对于带有换行的内容,目前无法去重。
### 请保证 csv 文件为 utf-8 编码
| question | answer |
| ------------- | ------------------------------------------------------ |
| 什么是 laf | laf 是一个云函数开发平台…… |
| 什么是 sealos | Sealos 是以 kubernetes 为内核的云操作系统发行版,可以…… |

View File

@@ -19,16 +19,16 @@ FastGpt 项目完全开源,可随意私有化部署,去除平台风险忧虑
| 计费项 | 价格: 元/ 1K tokens包含上下文|
| --- | --- |
| claude - 对话 | 免费 |
| chatgpt - 对话 | 0.03 |
| gpt4 - 对话 | 0.5 |
| 知识库 - 索引 | 免费 |
| 文件拆分 | 0.03 |
| chatgpt - 对话 | 0.025 |
| gpt4 - 对话 | 0.5 |
| 文件拆分 | 0.025 |
### 交流群/问题反馈
如果群满了,可加个小助手,定时拉
wx 号: fastgpt123
| 交流群 | 小助手 |
| ----------------------- | -------------------- |
| ![](/imgs/wxqun300.jpg) | ![](/imgs/wx300.jpg) |
| 交流群 | 小助手 |
| ------------------------------------------------- | ---------------------------------------------- |
| ![](https://otnvvf-imgs.oss.laf.run/wxqun300.jpg) | ![](https://otnvvf-imgs.oss.laf.run/wx300.png) |

View File

@@ -1,4 +1,3 @@
### Fast GPT V3.6
### Fast GPT V3.7
- 新增 - 分享免登录聊天框。可以直接为模型生成一个分享链接,其他人可以通过这个链接直接使用对话。
- 优化 - UI 细节。
- 新增 - 知识库与 AI 助手对多对关系,一个知识库可以被多个 AI 助手关联,一个 AI 助手可以关联多个知识库。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -41,7 +41,7 @@ export const streamFetch = ({ url, data, onMessage, abortSignal }: StreamFetchPr
return;
}
const text = decoder.decode(value).replace(/<br\/>/g, '\n');
const text = decoder.decode(value);
responseText += text;
onMessage(text);
read();

View File

@@ -1,8 +1,7 @@
import { GET, POST, DELETE, PUT } from './request';
import type { ModelSchema, ModelDataSchema } from '@/types/mongoSchema';
import type { ModelSchema } from '@/types/mongoSchema';
import type { ModelUpdateParams, ShareModelItem } from '@/types/model';
import { RequestPaging } from '../types/index';
import { Obj2Query } from '@/utils/tools';
import type { ModelListResponse } from './response/model';
/**
@@ -31,72 +30,6 @@ export const getModelById = (id: string) => GET<ModelSchema>(`/model/detail?mode
export const putModelById = (id: string, data: ModelUpdateParams) =>
PUT(`/model/update?modelId=${id}`, data);
/* 模型 data */
type GetModelDataListProps = RequestPaging & {
modelId: string;
searchText: string;
};
/**
* 获取模型的知识库数据
*/
export const getModelDataList = (props: GetModelDataListProps) =>
GET(`/model/data/getModelData?${Obj2Query(props)}`);
/**
* 获取导出数据(不分页)
*/
export const getExportDataList = (modelId: string) =>
GET<[string, string][]>(`/model/data/exportModelData?modelId=${modelId}`);
/**
* 获取模型正在拆分数据的数量
*/
export const getModelSplitDataListLen = (modelId: string) =>
GET<{
splitDataQueue: number;
embeddingQueue: number;
}>(`/model/data/getTrainingData?modelId=${modelId}`);
/**
* 获取 web 页面内容
*/
export const getWebContent = (url: string) => POST<string>(`/model/data/fetchingUrlData`, { url });
/**
* 手动输入数据
*/
export const postModelDataInput = (data: {
modelId: string;
data: { a: ModelDataSchema['a']; q: ModelDataSchema['q'] }[];
}) => POST<number>(`/model/data/pushModelDataInput`, data);
/**
* 拆分数据
*/
export const postModelDataSplitData = (data: {
modelId: string;
chunks: string[];
prompt: string;
mode: 'qa' | 'subsection';
}) => POST(`/model/data/splitData`, data);
/**
* json导入数据
*/
export const postModelDataCsvData = (modelId: string, data: string[][]) =>
POST<number>(`/model/data/pushModelDataCsv`, { modelId, data: data });
/**
* 更新模型数据
*/
export const putModelDataById = (data: { dataId: string; a: string; q?: string }) =>
PUT('/model/data/putModelData', data);
/**
* 删除一条模型数据
*/
export const delOneModelData = (dataId: string) =>
DELETE(`/model/data/delModelDataById?dataId=${dataId}`);
/* 共享市场 */
/**
* 获取共享市场模型

75
src/api/plugins/kb.ts Normal file
View File

@@ -0,0 +1,75 @@
import { GET, POST, PUT, DELETE } from '../request';
import type { KbItemType } from '@/types/plugin';
import { RequestPaging } from '@/types/index';
import { SplitTextTypEnum } from '@/constants/plugin';
import { KbDataItemType } from '@/types/plugin';
export type KbUpdateParams = { id: string; name: string; tags: string; avatar: string };
/* knowledge base */
export const getKbList = () => GET<KbItemType[]>(`/plugins/kb/list`);
export const getKbById = (id: string) => GET<KbItemType>(`/plugins/kb/detail?id=${id}`);
export const postCreateKb = (data: { name: string }) => POST<string>(`/plugins/kb/create`, data);
export const putKbById = (data: KbUpdateParams) => PUT(`/plugins/kb/update`, data);
export const delKbById = (id: string) => DELETE(`/plugins/kb/delete?id=${id}`);
/* kb data */
type GetKbDataListProps = RequestPaging & {
kbId: string;
searchText: string;
};
export const getKbDataList = (data: GetKbDataListProps) =>
POST(`/plugins/kb/data/getDataList`, data);
/**
* 获取导出数据(不分页)
*/
export const getExportDataList = (kbId: string) =>
GET<[string, string][]>(`/plugins/kb/data/exportModelData?kbId=${kbId}`);
/**
* 获取模型正在拆分数据的数量
*/
export const getTrainingData = (kbId: string) =>
GET<{
splitDataQueue: number;
embeddingQueue: number;
}>(`/plugins/kb/data/getTrainingData?kbId=${kbId}`);
/**
* 获取 web 页面内容
*/
export const getWebContent = (url: string) => POST<string>(`/model/data/fetchingUrlData`, { url });
/**
* 直接push数据
*/
export const postKbDataFromList = (data: {
kbId: string;
data: { a: KbDataItemType['a']; q: KbDataItemType['q'] }[];
}) => POST(`/openapi/kb/pushData`, data);
/**
* 更新一条数据
*/
export const putKbDataById = (data: { dataId: string; a: string; q?: string }) =>
PUT('/openapi/kb/updateData', data);
/**
* 删除一条知识库数据
*/
export const delOneKbDataByDataId = (dataId: string) =>
DELETE(`/openapi/kb/delDataById?dataId=${dataId}`);
/**
* 拆分数据
*/
export const postSplitData = (data: {
kbId: string;
chunks: string[];
prompt: string;
mode: `${SplitTextTypEnum}`;
}) => POST(`/openapi/text/splitText`, data);

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1684122143852" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2364" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M511.6 76.3C264.3 76.2 64 276.4 64 523.5 64 718.9 189.3 885 363.8 946c23.5 5.9 19.9-10.8 19.9-22.2v-77.5c-135.7 15.9-141.2-73.9-150.3-88.9C215 726 171.5 718 184.5 703c30.9-15.9 62.4 4 98.9 57.9 26.4 39.1 77.9 32.5 104 26 5.7-23.5 17.9-44.5 34.7-60.8-140.6-25.2-199.2-111-199.2-213 0-49.5 16.3-95 48.3-131.7-20.4-60.5 1.9-112.3 4.9-120 58.1-5.2 118.5 41.6 123.2 45.3 33-8.9 70.7-13.6 112.9-13.6 42.4 0 80.2 4.9 113.5 13.9 11.3-8.6 67.3-48.8 121.3-43.9 2.9 7.7 24.7 58.3 5.5 118 32.4 36.8 48.9 82.7 48.9 132.3 0 102.2-59 188.1-200 212.9 23.5 23.2 38.1 55.4 38.1 91v112.5c0.8 9 0 17.9 15 17.9 177.1-59.7 304.6-227 304.6-424.1 0-247.2-200.4-447.3-447.5-447.3z" p-id="2365"></path></svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1684163814302" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3451" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64"><path d="M512 384c-229.8 0-416-57.3-416-128v256c0 70.7 186.2 128 416 128s416-57.3 416-128V256c0 70.7-186.2 128-416 128z" p-id="3452"></path><path d="M512 704c-229.8 0-416-57.3-416-128v256c0 70.7 186.2 128 416 128s416-57.3 416-128V576c0 70.7-186.2 128-416 128zM512 320c229.8 0 416-57.3 416-128S741.8 64 512 64 96 121.3 96 192s186.2 128 416 128z" p-id="3453"></path></svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@@ -25,7 +25,9 @@ const map = {
tabbarMe: require('./icons/phoneTabbar/me.svg').default,
closeSolid: require('./icons/closeSolid.svg').default,
wx: require('./icons/wx.svg').default,
out: require('./icons/out.svg').default
out: require('./icons/out.svg').default,
git: require('./icons/git.svg').default,
kb: require('./icons/kb.svg').default
};
export type IconName = keyof typeof map;

View File

@@ -7,7 +7,8 @@ import { useQuery } from '@tanstack/react-query';
const unAuthPage: { [key: string]: boolean } = {
'/': true,
'/login': true,
'/model/share': true
'/model/share': true,
'/chat/share': true
};
const Auth = ({ children }: { children: JSX.Element }) => {

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useMemo } from 'react';
import { Box, useColorMode, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { useScreen } from '@/hooks/useScreen';
import { useLoading } from '@/hooks/useLoading';
import { useGlobalStore } from '@/store/global';
import { throttle } from 'lodash';
import Auth from './auth';
import Navbar from './navbar';
import NavbarPhone from './navbarPhone';
@@ -19,12 +19,11 @@ const phoneUnShowLayoutRoute: Record<string, boolean> = {
'/chat/share': true
};
const Layout = ({ children, isPcDevice }: { children: JSX.Element; isPcDevice: boolean }) => {
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const Layout = ({ children }: { children: JSX.Element }) => {
const router = useRouter();
const { colorMode, setColorMode } = useColorMode();
const { Loading } = useLoading();
const { loading } = useGlobalStore();
const { loading, setScreenWidth, isPc } = useGlobalStore();
const isChatPage = useMemo(
() => router.pathname === '/chat' && Object.values(router.query).join('').length !== 0,
@@ -37,6 +36,19 @@ const Layout = ({ children, isPcDevice }: { children: JSX.Element; isPcDevice: b
}
}, [colorMode, router.pathname, setColorMode]);
useEffect(() => {
const resize = throttle(() => {
setScreenWidth(document.documentElement.clientWidth);
}, 300);
resize();
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
};
}, [setScreenWidth]);
return (
<>
<Box
@@ -75,9 +87,3 @@ const Layout = ({ children, isPcDevice }: { children: JSX.Element; isPcDevice: b
};
export default Layout;
Layout.getInitialProps = ({ req }: any) => {
return {
isPcDevice: !/Mobile/.test(req ? req.headers['user-agent'] : navigator.userAgent)
};
};

View File

@@ -22,13 +22,18 @@ const Navbar = () => {
link: `/chat?modelId=${lastChatModelId}&chatId=${lastChatId}`,
activeLink: ['/chat']
},
{
label: 'AI助手',
icon: 'model',
link: `/model?modelId=${lastModelId}`,
activeLink: ['/model']
},
{
label: '知识库',
icon: 'kb',
link: `/kb`,
activeLink: ['/kb']
},
{
label: '共享',
icon: 'shareMarket',
@@ -125,6 +130,24 @@ const Navbar = () => {
</Tooltip>
))}
</Box>
<Box>
<Flex
mb={3}
flexDirection={'column'}
alignItems={'center'}
justifyContent={'center'}
cursor={'pointer'}
w={'60px'}
h={'45px'}
color={'#9096a5'}
_hover={{
color: '#ffffff'
}}
onClick={() => window.open('https://github.com/c121914yu/FastGPT')}
>
<MyIcon name={'git'} width={'22px'} height={'22px'} />
</Flex>
</Box>
</Flex>
);
};

View File

@@ -1,8 +1,8 @@
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { Box, Flex, useColorModeValue } from '@chakra-ui/react';
import { useCopyData } from '@/utils/tools';
import { useCopyData, formatLinkText } from '@/utils/tools';
import Icon from '@/components/Icon';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
@@ -12,9 +12,21 @@ import 'katex/dist/katex.min.css';
import styles from './index.module.scss';
import { codeLight } from './codeLight';
const Markdown = ({ source, isChatting = false }: { source: string; isChatting?: boolean }) => {
const Markdown = ({
source,
isChatting = false,
formatLink
}: {
source: string;
formatLink?: boolean;
isChatting?: boolean;
}) => {
const { copyData } = useCopyData();
const formatSource = useMemo(() => {
return formatLink ? formatLinkText(source) : source;
}, [source, formatLink]);
return (
<ReactMarkdown
className={`markdown ${styles.markdown} ${
@@ -63,7 +75,7 @@ const Markdown = ({ source, isChatting = false }: { source: string; isChatting?:
}}
linkTarget="_blank"
>
{source}
{formatSource}
</ReactMarkdown>
);
};

View File

@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import type { BoxProps } from '@chakra-ui/react';
import MyIcon from '../Icon';
interface Props extends BoxProps {}
const SideBar = (e?: Props) => {
const {
w = ['100%', '0 0 250px', '0 0 280px', '0 0 310px', '0 0 340px'],
children,
...props
} = e || {};
const [foldSideBar, setFoldSideBar] = useState(false);
return (
<Box
position={'relative'}
flex={foldSideBar ? '0 0 0' : w}
w={['100%', 0]}
h={'100%'}
zIndex={1}
transition={'0.2s'}
_hover={{
'& > div': { visibility: 'visible', opacity: 1 }
}}
{...props}
>
<Flex
position={'absolute'}
right={0}
top={'50%'}
transform={'translate(50%,-50%)'}
alignItems={'center'}
justifyContent={'flex-end'}
pr={1}
w={'36px'}
h={'50px'}
borderRadius={'10px'}
bg={'rgba(0,0,0,0.5)'}
cursor={'pointer'}
transition={'0.2s'}
{...(foldSideBar
? {
opacity: 0.6
}
: {
visibility: 'hidden',
opacity: 0
})}
onClick={() => setFoldSideBar(!foldSideBar)}
>
<MyIcon
name={'back'}
transform={foldSideBar ? 'rotate(180deg)' : ''}
w={'14px'}
color={'white'}
/>
</Flex>
<Box position={'relative'} h={'100%'} overflow={foldSideBar ? 'hidden' : 'visible'}>
{children}
</Box>
</Box>
);
};
export default SideBar;

View File

@@ -23,7 +23,7 @@ const WxConcat = ({ onClose }: { onClose: () => void }) => {
<ModalBody textAlign={'center'}>
<Image
style={{ margin: 'auto' }}
src={'/imgs/wx300.jpg'}
src={'https://otnvvf-imgs.oss.laf.run/wx300.png'}
width={'200px'}
height={'200px'}
alt=""

11
src/constants/kb.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { KbItemType } from '@/types/plugin';
export const defaultKbDetail: KbItemType = {
_id: '',
userId: '',
updateTime: new Date(),
avatar: '/icon/logo.png',
name: '',
tags: '',
totalData: 0
};

View File

@@ -1,6 +1,6 @@
import { getSystemModelList } from '@/api/system';
import type { ModelSchema } from '@/types/mongoSchema';
import type { ShareChatEditType } from '@/types/model';
import type { ModelSchema } from '@/types/mongoSchema';
export const embeddingModel = 'text-embedding-ada-002';
export type EmbeddingModelType = 'text-embedding-ada-002';
@@ -32,7 +32,7 @@ export const ChatModelMap = {
contextMaxToken: 4096,
systemMaxToken: 2400,
maxTemperature: 1.2,
price: 3
price: 2.5
},
[OpenAiChatEnum.GPT4]: {
chatModel: OpenAiChatEnum.GPT4,
@@ -142,7 +142,7 @@ export const defaultModel: ModelSchema = {
status: ModelStatusEnum.pending,
updateTime: Date.now(),
chat: {
useKb: false,
relatedKbs: [],
searchMode: ModelVectorSearchModeEnum.hightSimilarity,
systemPrompt: '',
temperature: 0,
@@ -153,13 +153,6 @@ export const defaultModel: ModelSchema = {
isShareDetail: false,
intro: '',
collection: 0
},
security: {
domain: ['*'],
contextMaxLen: 1,
contentMaxLen: 1,
expiredTime: 9999,
maxLoadAmount: 1
}
};

4
src/constants/plugin.ts Normal file
View File

@@ -0,0 +1,4 @@
export enum SplitTextTypEnum {
'qa' = 'qa',
'subsection' = 'subsection'
}

View File

@@ -1,6 +1,6 @@
import { extendTheme, defineStyleConfig, ComponentStyleConfig } from '@chakra-ui/react';
// @ts-ignore
import { modalAnatomy, switchAnatomy, selectAnatomy } from '@chakra-ui/anatomy';
import { modalAnatomy, switchAnatomy, selectAnatomy, checkboxAnatomy } from '@chakra-ui/anatomy';
// @ts-ignore
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
@@ -11,6 +11,8 @@ const { definePartsStyle: switchPart, defineMultiStyleConfig: switchMultiStyle }
createMultiStyleConfigHelpers(switchAnatomy.keys);
const { definePartsStyle: selectPart, defineMultiStyleConfig: selectMultiStyle } =
createMultiStyleConfigHelpers(selectAnatomy.keys);
const { definePartsStyle: checkboxPart, defineMultiStyleConfig: checkboxMultiStyle } =
createMultiStyleConfigHelpers(checkboxAnatomy.keys);
// modal 弹窗
const ModalTheme = defineMultiStyleConfig({
@@ -171,6 +173,9 @@ export const theme = extendTheme({
fontWeight: 400,
height: '100%',
overflow: 'hidden'
},
a: {
color: 'myBlue.700'
}
}
},

View File

@@ -41,6 +41,7 @@ export const usePagination = <T = any,>({
});
console.log(error);
}
return null;
}
});

View File

@@ -4,7 +4,9 @@ function Error({ errStr }: { errStr: string }) {
Error.getInitialProps = ({ res, err }: { res: any; err: any }) => {
console.log(err);
return { errStr: JSON.stringify(err) };
return {
errStr: `部分系统不兼容,导致页面崩溃。如果可以,请联系作者,反馈下具体操作和页面。大部分是 苹果 的 safari 浏览器导致,可以尝试更换 chrome 浏览器。`
};
};
export default Error;

View File

@@ -4,7 +4,6 @@ import { authChat } from '@/service/utils/auth';
import { modelServiceToolMap } from '@/service/utils/chat';
import { ChatItemSimpleType } from '@/types/chat';
import { jsonRes } from '@/service/response';
import { PassThrough } from 'stream';
import { ChatModelMap, ModelVectorSearchModeMap } from '@/constants/model';
import { pushChatBill } from '@/service/events/pushBill';
import { resStreamResponse } from '@/service/utils/chat';
@@ -14,17 +13,9 @@ import { ChatRoleEnum } from '@/constants/chat';
/* 发送提示词 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
let step = 0; // step=1时表示开始了流响应
const stream = new PassThrough();
stream.on('error', () => {
console.log('error: ', 'stream error');
stream.destroy();
});
res.on('close', () => {
stream.destroy();
});
res.on('error', () => {
console.log('error: ', 'request error');
stream.destroy();
res.end();
});
try {
@@ -54,7 +45,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const prompts = [...content, prompt];
// 使用了知识库搜索
if (model.chat.useKb) {
if (model.chat.relatedKbs.length > 0) {
const { code, searchPrompts } = await searchKb({
userOpenAiKey,
prompts,
@@ -100,7 +91,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { totalTokens, finishMessages } = await resStreamResponse({
model: model.chat.chatModel,
res,
stream,
chatResponse: streamResponse,
prompts,
systemPrompt: showModelDetail
@@ -123,8 +113,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} catch (err: any) {
if (step === 1) {
// 直接结束流
res.end();
console.log('error结束');
stream.destroy();
} else {
res.status(500);
jsonRes(res, {

View File

@@ -4,7 +4,6 @@ import { authShareChat } from '@/service/utils/auth';
import { modelServiceToolMap } from '@/service/utils/chat';
import { ChatItemSimpleType } from '@/types/chat';
import { jsonRes } from '@/service/response';
import { PassThrough } from 'stream';
import { ChatModelMap, ModelVectorSearchModeMap } from '@/constants/model';
import { pushChatBill, updateShareChatBill } from '@/service/events/pushBill';
import { resStreamResponse } from '@/service/utils/chat';
@@ -14,17 +13,9 @@ import { ChatRoleEnum } from '@/constants/chat';
/* 发送提示词 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
let step = 0; // step=1 时,表示开始了流响应
const stream = new PassThrough();
stream.on('error', () => {
console.log('error: ', 'stream error');
stream.destroy();
});
res.on('close', () => {
stream.destroy();
});
res.on('error', () => {
console.log('error: ', 'request error');
stream.destroy();
res.end();
});
try {
@@ -42,7 +33,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await connectToDatabase();
let startTime = Date.now();
const { model, showModelDetail, userOpenAiKey, systemAuthKey, userId } = await authShareChat({
const { model, userOpenAiKey, systemAuthKey, userId } = await authShareChat({
shareId,
password
});
@@ -50,7 +41,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const modelConstantsData = ChatModelMap[model.chat.chatModel];
// 使用了知识库搜索
if (model.chat.useKb) {
if (model.chat.relatedKbs.length > 0) {
const { code, searchPrompts } = await searchKb({
userOpenAiKey,
prompts,
@@ -96,7 +87,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { totalTokens, finishMessages } = await resStreamResponse({
model: model.chat.chatModel,
res,
stream,
chatResponse: streamResponse,
prompts,
systemPrompt: ''
@@ -117,8 +107,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} catch (err: any) {
if (step === 1) {
// 直接结束流
res.end();
console.log('error结束');
stream.destroy();
} else {
res.status(500);
jsonRes(res, {

View File

@@ -16,7 +16,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const userId = await authToken(req);
await authModel({
modelId,
userId
userId,
authOwner: false
});
const { _id } = await ShareChat.create({

View File

@@ -36,7 +36,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// 校验使用权限
const { model } = await authModel({
modelId: shareChat.modelId,
userId: String(shareChat.userId)
userId: String(shareChat.userId),
authOwner: false
});
jsonRes<InitShareChatResponse>(res, {

View File

@@ -1,34 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import axios from 'axios';
import { axiosConfig } from '@/service/utils/tools';
/**
* 读取网站的内容
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { url } = req.body as { url: string };
if (!url) {
throw new Error('缺少 url');
}
await connectToDatabase();
await authToken(req);
const data = await axios
.get(url, {
httpsAgent: axiosConfig().httpsAgent
})
.then((res) => res.data as string);
jsonRes(res, { data });
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -1,54 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import { ModelDataSchema } from '@/types/mongoSchema';
import { generateVector } from '@/service/events/generateVector';
import { PgClient } from '@/service/pg';
import { authModel } from '@/service/utils/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { modelId, data } = req.body as {
modelId: string;
data: { a: ModelDataSchema['a']; q: ModelDataSchema['q'] }[];
};
if (!modelId || !Array.isArray(data)) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(req);
await connectToDatabase();
// 验证是否是该用户的 model
await authModel({
userId,
modelId
});
// 插入记录
await PgClient.insert('modelData', {
values: data.map((item) => [
{ key: 'user_id', value: userId },
{ key: 'model_id', value: modelId },
{ key: 'q', value: item.q },
{ key: 'a', value: item.a },
{ key: 'status', value: 'waiting' }
])
});
generateVector();
jsonRes(res, {
data: 0
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -2,7 +2,6 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { Chat, Model, connectToDatabase, Collection, ShareChat } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import { PgClient } from '@/service/pg';
import { authModel } from '@/service/utils/auth';
/* 获取我的模型 */
@@ -25,11 +24,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
userId
});
// 删除 pg 中所有该模型的数据
await PgClient.delete('modelData', {
where: [['user_id', userId], 'AND', ['model_id', modelId]]
});
// 删除对应的聊天
await Chat.deleteMany({
modelId

View File

@@ -9,10 +9,10 @@ import { authModel } from '@/service/utils/auth';
/* 获取我的模型 */
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { name, avatar, chat, share, security } = req.body as ModelUpdateParams;
const { name, avatar, chat, share } = req.body as ModelUpdateParams;
const { modelId } = req.query as { modelId: string };
if (!name || !chat || !security || !modelId) {
if (!name || !chat || !modelId) {
throw new Error('参数错误');
}
@@ -38,8 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
chat,
'share.isShare': share.isShare,
'share.isShareDetail': share.isShareDetail,
'share.intro': share.intro,
security
'share.intro': share.intro
}
);

View File

@@ -4,26 +4,18 @@ import { authOpenApiKey, authModel, getApiKey } from '@/service/utils/auth';
import { modelServiceToolMap, resStreamResponse } from '@/service/utils/chat';
import { ChatItemSimpleType } from '@/types/chat';
import { jsonRes } from '@/service/response';
import { PassThrough } from 'stream';
import { ChatModelMap, ModelVectorSearchModeMap } from '@/constants/model';
import { pushChatBill } from '@/service/events/pushBill';
import { searchKb } from '@/service/plugins/searchKb';
import { ChatRoleEnum } from '@/constants/chat';
import { withNextCors } from '@/service/utils/tools';
/* 发送提示词 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse) {
let step = 0; // step=1时表示开始了流响应
const stream = new PassThrough();
stream.on('error', () => {
console.log('error: ', 'stream error');
stream.destroy();
});
res.on('close', () => {
stream.destroy();
});
res.on('error', () => {
console.log('error: ', 'request error');
stream.destroy();
res.end();
});
try {
@@ -70,7 +62,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const modelConstantsData = ChatModelMap[model.chat.chatModel];
// 使用了知识库搜索
if (model.chat.useKb) {
if (model.chat.relatedKbs.length > 0) {
const similarity = ModelVectorSearchModeMap[model.chat.searchMode]?.similarity || 0.22;
const { code, searchPrompts } = await searchKb({
@@ -120,7 +112,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { finishMessages, totalTokens } = await resStreamResponse({
model: model.chat.chatModel,
res,
stream,
chatResponse: streamResponse,
prompts
});
@@ -143,8 +134,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} catch (err: any) {
if (step === 1) {
// 直接结束流
res.end();
console.log('error结束');
stream.destroy();
} else {
res.status(500);
jsonRes(res, {
@@ -153,4 +144,4 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
}
}
}
});

View File

@@ -1,194 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { connectToDatabase } from '@/service/mongo';
import { authOpenApiKey, authModel, getApiKey } from '@/service/utils/auth';
import { resStreamResponse, modelServiceToolMap } from '@/service/utils/chat';
import { ChatItemSimpleType } from '@/types/chat';
import { jsonRes } from '@/service/response';
import { PassThrough } from 'stream';
import { ChatModelMap, ModelVectorSearchModeMap } from '@/constants/model';
import { pushChatBill } from '@/service/events/pushBill';
import { searchKb } from '@/service/plugins/searchKb';
import { ChatRoleEnum } from '@/constants/chat';
/* 发送提示词 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
let step = 0; // step=1时表示开始了流响应
const stream = new PassThrough();
stream.on('error', () => {
console.log('error: ', 'stream error');
stream.destroy();
});
res.on('close', () => {
stream.destroy();
});
res.on('error', () => {
console.log('error: ', 'request error');
stream.destroy();
});
try {
const {
prompt,
modelId,
isStream = true
} = req.body as {
prompt: ChatItemSimpleType;
modelId: string;
isStream: boolean;
};
if (!prompt || !modelId) {
throw new Error('缺少参数');
}
await connectToDatabase();
let startTime = Date.now();
/* 凭证校验 */
const { userId } = await authOpenApiKey(req);
/* 查找数据库里的模型信息 */
const { model } = await authModel({
userId,
modelId
});
/* get api key */
const { systemAuthKey: apiKey } = await getApiKey({
model: model.chat.chatModel,
userId,
mustPay: true
});
const modelConstantsData = ChatModelMap[model.chat.chatModel];
console.log('laf gpt start');
// 请求一次 chatgpt 拆解需求
const { responseText: resolveText, totalTokens: resolveTokens } = await modelServiceToolMap[
model.chat.chatModel
].chatCompletion({
apiKey,
temperature: 0,
messages: [
{
obj: ChatRoleEnum.System,
value: `服务端逻辑生成器.根据用户输入的需求,拆解成 laf 云函数实现的步骤,只返回步骤,按格式返回步骤: 1.\n2.\n3.\n ......
下面是一些例子:
一个 hello world 例子
1. 返回字符串: "hello world"
计算圆的面积
1. 从 body 中获取半径 radius.
2. 校验 radius 是否为有效的数字.
3. 计算圆的面积.
4. 返回圆的面积: {area}
实现一个手机号发生注册验证码方法.
1. 从 query 中获取 phone.
2. 校验手机号格式是否正确,不正确则返回错误原因:手机号格式错误.
3. 给 phone 发送一个短信验证码,验证码长度为6位字符串,内容为:你正在注册laf,验证码为:code.
4. 数据库添加数据,表为"codes",内容为 {phone, code}.
实现一个云函数,使用手机号注册账号,需要验证手机验证码.
1. 从 body 中获取 phone 和 code.
2. 校验手机号格式是否正确,不正确则返回错误原因:手机号格式错误.
2. 获取数据库数据,表为"codes",查找是否有符合 phone, code 等于body参数的记录,没有的话返回错误原因:验证码不正确.
4. 添加数据库数据,表为"users" ,内容为{phone, code, createTime}.
5. 删除数据库数据,删除 code 记录.
6. 返回新建用户的Id: return {userId}
更新博客记录。传入blogId,blogText,tags,还需要记录更新的时间.
1. 从 body 中获取 blogId,blogText 和 tags.
2. 校验 blogId 是否为空,为空则返回错误原因:博客ID不能为空.
3. 校验 blogText 是否为空,为空则返回错误原因:博客内容不能为空.
4. 校验 tags 是否为数组,不是则返回错误原因:标签必须为数组.
5. 获取当前时间,记录为 updateTime.
6. 更新数据库数据,表为"blogs",更新符合 blogId 的记录的内容为{blogText, tags, updateTime}.
7. 返回结果 "更新博客记录成功"`
},
{
obj: ChatRoleEnum.Human,
value: prompt.value
}
],
stream: false
});
prompt.value += ` ${resolveText}`;
console.log('prompt resolve success, time:', `${(Date.now() - startTime) / 1000}s`);
// 读取对话内容
const prompts = [prompt];
// 获取向量匹配到的提示词
const { searchPrompts } = await searchKb({
similarity: ModelVectorSearchModeMap[model.chat.searchMode]?.similarity,
prompts,
model,
userId
});
prompts.splice(prompts.length - 1, 0, ...searchPrompts);
// 计算温度
const temperature = (modelConstantsData.maxTemperature * (model.chat.temperature / 10)).toFixed(
2
);
// 发出请求
const { streamResponse, responseMessages, responseText, totalTokens } =
await modelServiceToolMap[model.chat.chatModel].chatCompletion({
apiKey,
temperature: +temperature,
messages: prompts,
stream: isStream
});
console.log('api response time:', `${(Date.now() - startTime) / 1000}s`);
let textLen = resolveText.length;
let tokens = resolveTokens;
if (isStream) {
step = 1;
const { finishMessages, totalTokens } = await resStreamResponse({
model: model.chat.chatModel,
res,
stream,
chatResponse: streamResponse,
prompts
});
textLen += finishMessages.map((item) => item.value).join('').length;
tokens += totalTokens;
} else {
textLen += responseMessages.map((item) => item.value).join('').length;
tokens += totalTokens;
jsonRes(res, {
data: responseText
});
}
console.log('laf gpt done. time:', `${(Date.now() - startTime) / 1000}s`);
pushChatBill({
isPay: true,
chatModel: model.chat.chatModel,
userId,
textLen,
tokens
});
} catch (err: any) {
if (step === 1) {
// 直接结束流
console.log('error结束');
stream.destroy();
} else {
res.status(500);
jsonRes(res, {
code: 500,
error: err
});
}
}
}

View File

@@ -2,8 +2,9 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { authToken } from '@/service/utils/auth';
import { PgClient } from '@/service/pg';
import { withNextCors } from '@/service/utils/tools';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
let { dataId } = req.query as {
dataId: string;
@@ -28,4 +29,4 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
error: err
});
}
}
});

View File

@@ -1,20 +1,26 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { KbDataItemType } from '@/types/plugin';
import { jsonRes } from '@/service/response';
import { connectToDatabase } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import { generateVector } from '@/service/events/generateVector';
import { ModelDataStatusEnum } from '@/constants/model';
import { PgClient } from '@/service/pg';
import { authModel } from '@/service/utils/auth';
import { authKb } from '@/service/utils/auth';
import { withNextCors } from '@/service/utils/tools';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { modelId, data } = req.body as {
modelId: string;
data: string[][];
const {
kbId,
data,
formatLineBreak = true
} = req.body as {
kbId: string;
formatLineBreak?: boolean;
data: { a: KbDataItemType['a']; q: KbDataItemType['q'] }[];
};
if (!modelId || !Array.isArray(data)) {
if (!kbId || !Array.isArray(data)) {
throw new Error('缺少参数');
}
@@ -23,31 +29,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
await connectToDatabase();
// 验证是否是该用户的 model
await authModel({
await authKb({
userId,
modelId
kbId
});
// 去重
// 过滤重复的内容
const searchRes = await Promise.allSettled(
data.map(async ([q, a = '']) => {
data.map(async ({ q, a = '' }) => {
if (!q) {
return Promise.reject('q为空');
}
try {
if (formatLineBreak) {
q = q.replace(/\\n/g, '\n');
a = a.replace(/\\n/g, '\n');
}
// Exactly the same data, not push
try {
const count = await PgClient.count('modelData', {
where: [
['user_id', userId],
'AND',
['model_id', modelId],
'AND',
['q', q],
'AND',
['a', a]
]
where: [['user_id', userId], 'AND', ['kb_id', kbId], 'AND', ['q', q], 'AND', ['a', a]]
});
if (count > 0) {
return Promise.reject('已经存在');
@@ -61,25 +63,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
});
})
);
// 过滤重复的内容
const filterData = searchRes
.filter((item) => item.status === 'fulfilled')
.map<{ q: string; a: string }>((item: any) => item.value);
// 插入 pg
// 插入记录
const insertRes = await PgClient.insert('modelData', {
values: filterData.map((item) => [
{ key: 'user_id', value: userId },
{ key: 'model_id', value: modelId },
{ key: 'kb_id', value: kbId },
{ key: 'q', value: item.q },
{ key: 'a', value: item.a },
{ key: 'status', value: ModelDataStatusEnum.waiting }
{ key: 'status', value: 'waiting' }
])
});
generateVector();
jsonRes(res, {
message: `共插入 ${insertRes.rowCount} 条数据`,
data: insertRes.rowCount
});
} catch (err) {
@@ -88,4 +90,4 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
error: err
});
}
}
});

View File

@@ -4,8 +4,9 @@ import { authToken } from '@/service/utils/auth';
import { ModelDataStatusEnum } from '@/constants/model';
import { generateVector } from '@/service/events/generateVector';
import { PgClient } from '@/service/pg';
import { withNextCors } from '@/service/utils/tools';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { dataId, a, q } = req.body as { dataId: string; a: string; q?: string };
@@ -16,7 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
// 凭证校验
const userId = await authToken(req);
// 更新 pg 内容
// 更新 pg 内容.仅修改a不需要更新向量。
await PgClient.update('modelData', {
where: [['id', dataId], 'AND', ['user_id', userId]],
values: [
@@ -39,4 +40,4 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
error: err
});
}
}
});

View File

@@ -1,21 +1,23 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, SplitData } from '@/service/mongo';
import { authModel, authToken } from '@/service/utils/auth';
import { authKb, authToken } from '@/service/utils/auth';
import { generateVector } from '@/service/events/generateVector';
import { generateQA } from '@/service/events/generateQA';
import { PgClient } from '@/service/pg';
import { SplitTextTypEnum } from '@/constants/plugin';
import { withNextCors } from '@/service/utils/tools';
/* 拆分数据成QA */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
/* split text */
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { chunks, modelId, prompt, mode } = req.body as {
modelId: string;
const { chunks, kbId, prompt, mode } = req.body as {
kbId: string;
chunks: string[];
prompt: string;
mode: 'qa' | 'subsection';
mode: `${SplitTextTypEnum}`;
};
if (!chunks || !modelId || !prompt) {
if (!chunks || !kbId || !prompt) {
throw new Error('参数错误');
}
await connectToDatabase();
@@ -23,27 +25,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const userId = await authToken(req);
// 验证是否是该用户的 model
await authModel({
modelId,
await authKb({
kbId,
userId
});
if (mode === 'qa') {
if (mode === SplitTextTypEnum.qa) {
// 批量QA拆分插入数据
await SplitData.create({
userId,
modelId,
kbId,
textList: chunks,
prompt
});
generateQA();
} else if (mode === 'subsection') {
} else if (mode === SplitTextTypEnum.subsection) {
// 待优化,直接调用另一个接口
// 插入记录
await PgClient.insert('modelData', {
values: chunks.map((item) => [
{ key: 'user_id', value: userId },
{ key: 'model_id', value: modelId },
{ key: 'kb_id', value: kbId },
{ key: 'q', value: item },
{ key: 'a', value: '' },
{ key: 'status', value: 'waiting' }
@@ -60,7 +63,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
error: err
});
}
}
});
export const config = {
api: {

View File

@@ -0,0 +1,35 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, KB } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { name, tags } = req.body as {
name: string;
tags: string[];
};
if (!name) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(req);
await connectToDatabase();
const { _id } = await KB.create({
name,
userId,
tags
});
jsonRes(res, { data: _id });
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -6,11 +6,11 @@ import { PgClient } from '@/service/pg';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
let { modelId } = req.query as {
modelId: string;
let { kbId } = req.query as {
kbId: string;
};
if (!modelId) {
if (!kbId) {
throw new Error('缺少参数');
}
@@ -21,11 +21,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
// 统计数据
const count = await PgClient.count('modelData', {
where: [['model_id', modelId], 'AND', ['user_id', userId]]
where: [['kb_id', kbId], 'AND', ['user_id', userId]]
});
// 从 pg 中获取所有数据
const pgData = await PgClient.select<{ q: string; a: string }>('modelData', {
where: [['model_id', modelId], 'AND', ['user_id', userId]],
where: [['kb_id', kbId], 'AND', ['user_id', userId]],
fields: ['q', 'a'],
order: [{ field: 'id', mode: 'DESC' }],
limit: count

View File

@@ -3,27 +3,22 @@ import { jsonRes } from '@/service/response';
import { connectToDatabase } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import { PgClient } from '@/service/pg';
import type { PgModelDataItemType } from '@/types/pg';
import { authModel } from '@/service/utils/auth';
import type { PgKBDataItemType } from '@/types/pg';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
let {
modelId,
kbId,
pageNum = 1,
pageSize = 10,
searchText = ''
} = req.query as {
modelId: string;
pageNum: string;
pageSize: string;
} = req.body as {
kbId: string;
pageNum: number;
pageSize: number;
searchText: string;
};
pageNum = +pageNum;
pageSize = +pageSize;
if (!modelId) {
if (!kbId) {
throw new Error('缺少参数');
}
@@ -32,19 +27,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
await connectToDatabase();
const { model } = await authModel({
userId,
modelId,
authOwner: false
});
const where: any = [
...(model.share.isShareDetail ? [] : [['user_id', userId], 'AND']),
['model_id', modelId],
['user_id', userId],
'AND',
['kb_id', kbId],
...(searchText ? ['AND', `(q LIKE '%${searchText}%' OR a LIKE '%${searchText}%')`] : [])
];
const searchRes = await PgClient.select<PgModelDataItemType>('modelData', {
const searchRes = await PgClient.select<PgKBDataItemType>('modelData', {
fields: ['id', 'q', 'a', 'status'],
where,
order: [{ field: 'id', mode: 'DESC' }],

View File

@@ -8,8 +8,8 @@ import { PgClient } from '@/service/pg';
/* 拆分数据成QA */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { modelId } = req.query as { modelId: string };
if (!modelId) {
const { kbId } = req.query as { kbId: string };
if (!kbId) {
throw new Error('参数错误');
}
await connectToDatabase();
@@ -19,25 +19,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// split queue data
const data = await SplitData.find({
userId,
modelId,
kbId,
textList: { $exists: true, $not: { $size: 0 } }
});
// embedding queue data
const where: any = [
['user_id', userId],
'AND',
['model_id', modelId],
'AND',
['status', ModelDataStatusEnum.waiting]
];
const embeddingData = await PgClient.count('modelData', {
where: [
['user_id', userId],
'AND',
['kb_id', kbId],
'AND',
['status', ModelDataStatusEnum.waiting]
]
});
jsonRes(res, {
data: {
splitDataQueue: data.map((item) => item.textList).flat().length,
embeddingQueue: await PgClient.count('modelData', {
where
})
embeddingQueue: embeddingData
}
});
} catch (err) {

View File

@@ -0,0 +1,43 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, KB } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import { PgClient } from '@/service/pg';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { id } = req.query as {
id: string;
};
if (!id) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(req);
await connectToDatabase();
// delete mongo data
await KB.findOneAndDelete({
_id: id,
userId
});
// delete all pg data
// 删除 pg 中所有该模型的数据
await PgClient.delete('modelData', {
where: [['user_id', userId], 'AND', ['kb_id', id]]
});
// delete related model
jsonRes(res);
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -0,0 +1,46 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, KB } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { id } = req.query as {
id: string;
};
if (!id) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(req);
await connectToDatabase();
const data = await KB.findOne({
_id: id,
userId
});
if (!data) {
throw new Error('kb is not exist');
}
jsonRes(res, {
data: {
_id: data._id,
avatar: data.avatar,
name: data.name,
userId: data.userId,
updateTime: data.updateTime,
tags: data.tags.join(' ')
}
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -0,0 +1,42 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, KB } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import { PgClient } from '@/service/pg';
import { KbItemType } from '@/types/plugin';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
// 凭证校验
const userId = await authToken(req);
await connectToDatabase();
const kbList = await KB.find({
userId
}).sort({ updateTime: -1 });
const data = await Promise.all(
kbList.map(async (item) => ({
_id: item._id,
avatar: item.avatar,
name: item.name,
userId: item.userId,
updateTime: item.updateTime,
tags: item.tags.join(' '),
totalData: await PgClient.count('modelData', {
where: [['user_id', userId], 'AND', ['kb_id', item._id]]
})
}))
);
jsonRes<KbItemType[]>(res, {
data
});
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/service/response';
import { connectToDatabase, KB } from '@/service/mongo';
import { authToken } from '@/service/utils/auth';
import type { KbUpdateParams } from '@/api/plugins/kb';
export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const { id, name, tags, avatar } = req.body as KbUpdateParams;
if (!id || !name) {
throw new Error('缺少参数');
}
// 凭证校验
const userId = await authToken(req);
await connectToDatabase();
await KB.findOneAndUpdate(
{
_id: id,
userId
},
{
avatar,
name,
tags: tags.split(' ').filter((item) => item)
}
);
jsonRes(res);
} catch (err) {
jsonRes(res, {
code: 500,
error: err
});
}
}

View File

@@ -5,8 +5,10 @@ import Markdown from '@/components/Markdown';
import { LOGO_ICON } from '@/constants/chat';
const Empty = ({
showChatProblem,
model: { name, intro, avatar }
}: {
showChatProblem: boolean;
model: {
name: string;
intro: string;
@@ -43,13 +45,18 @@ const Empty = ({
<Box whiteSpace={'pre-line'}>{intro}</Box>
</Card>
)}
{/* version intro */}
<Card p={4} mb={10}>
<Markdown source={versionIntro} />
</Card>
<Card p={4}>
<Markdown source={chatProblem} />
</Card>
{showChatProblem && (
<>
{/* version intro */}
<Card p={4} mb={10}>
<Markdown source={versionIntro} />
</Card>
<Card p={4}>
<Markdown source={chatProblem} />
</Card>
</>
)}
</Box>
);
};

View File

@@ -20,24 +20,22 @@ import { formatTimeToChatTime } from '@/utils/tools';
import MyIcon from '@/components/Icon';
import type { HistoryItemType, ExportChatType } from '@/types/chat';
import { useChatStore } from '@/store/chat';
import { useScreen } from '@/hooks/useScreen';
import ModelList from './ModelList';
import { useGlobalStore } from '@/store/global';
import styles from '../index.module.scss';
const PcSliderBar = ({
isPcDevice,
onclickDelHistory,
onclickExportChat
}: {
isPcDevice: boolean;
onclickDelHistory: (historyId: string) => Promise<void>;
onclickExportChat: (type: ExportChatType) => void;
}) => {
const router = useRouter();
const { modelId = '', chatId = '' } = router.query as { modelId: string; chatId: string };
const theme = useTheme();
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const { isPc } = useGlobalStore();
const ContextMenuRef = useRef(null);

View File

@@ -17,17 +17,15 @@ import { formatTimeToChatTime } from '@/utils/tools';
import MyIcon from '@/components/Icon';
import type { ShareChatHistoryItemType, ExportChatType } from '@/types/chat';
import { useChatStore } from '@/store/chat';
import { useScreen } from '@/hooks/useScreen';
import { useGlobalStore } from '@/store/global';
import styles from '../index.module.scss';
const PcSliderBar = ({
isPcDevice,
onclickDelHistory,
onclickExportChat,
onCloseSlider
}: {
isPcDevice: boolean;
onclickDelHistory: (historyId: string) => void;
onclickExportChat: (type: ExportChatType) => void;
onCloseSlider: () => void;
@@ -35,7 +33,7 @@ const PcSliderBar = ({
const router = useRouter();
const { shareId = '', historyId = '' } = router.query as { shareId: string; historyId: string };
const theme = useTheme();
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const { isPc } = useGlobalStore();
const ContextMenuRef = useRef(null);

View File

@@ -33,15 +33,14 @@ import {
useTheme
} from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useScreen } from '@/hooks/useScreen';
import { useGlobalStore } from '@/store/global';
import { useQuery } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useCopyData, voiceBroadcast } from '@/utils/tools';
import { useCopyData, voiceBroadcast, hasVoiceApi } from '@/utils/tools';
import { streamFetch } from '@/api/fetch';
import MyIcon from '@/components/Icon';
import { throttle } from 'lodash';
import { Types } from 'mongoose';
import Markdown from '@/components/Markdown';
import { LOGO_ICON } from '@/constants/chat';
import { ChatModelMap } from '@/constants/model';
import { useChatStore } from '@/store/chat';
@@ -50,34 +49,23 @@ import { fileDownload } from '@/utils/file';
import { htmlTemplate } from '@/constants/common';
import { useUserStore } from '@/store/user';
import Loading from '@/components/Loading';
import Markdown from '@/components/Markdown';
import SideBar from '@/components/SideBar';
import Empty from './components/Empty';
const PhoneSliderBar = dynamic(() => import('./components/PhoneSliderBar'), {
loading: () => <Loading fixed={false} />,
ssr: false
});
const History = dynamic(() => import('./components/History'), {
loading: () => <Loading fixed={false} />,
ssr: false
});
const Empty = dynamic(() => import('./components/Empty'), {
loading: () => <Loading fixed={false} />,
ssr: false
});
import styles from './index.module.scss';
const textareaMinH = '22px';
const Chat = ({
modelId,
chatId,
isPcDevice
}: {
modelId: string;
chatId: string;
isPcDevice: boolean;
}) => {
const hasVoiceApi = !!window.speechSynthesis;
const Chat = ({ modelId, chatId }: { modelId: string; chatId: string }) => {
const router = useRouter();
const theme = useTheme();
@@ -90,7 +78,6 @@ const Chat = ({
const controller = useRef(new AbortController());
const isLeavePage = useRef(false);
const [inputVal, setInputVal] = useState(''); // user input prompt
const [showSystemPrompt, setShowSystemPrompt] = useState('');
const [messageContextMenuData, setMessageContextMenuData] = useState<{
// message messageContextMenuData
@@ -98,7 +85,6 @@ const Chat = ({
top: number;
message: ChatSiteItemType;
}>();
const [foldSliderBar, setFoldSlideBar] = useState(false);
const {
lastChatModelId,
@@ -119,7 +105,7 @@ const Chat = ({
const { toast } = useToast();
const { copyData } = useCopyData();
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const { isPc } = useGlobalStore();
const { Loading, setIsLoading } = useLoading();
const { userInfo } = useUserStore();
const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure();
@@ -168,7 +154,8 @@ const Chat = ({
// 重置输入内容
const resetInputVal = useCallback((val: string) => {
setInputVal(val);
if (!TextareaDom.current) return;
TextareaDom.current.value = val;
setTimeout(() => {
/* 回到最小高度 */
if (TextareaDom.current) {
@@ -289,6 +276,7 @@ const Chat = ({
* 发送一个内容
*/
const sendPrompt = useCallback(async () => {
// get value
if (isChatting) {
toast({
title: '正在聊天中...请等待结束',
@@ -296,9 +284,10 @@ const Chat = ({
});
return;
}
const storeInput = inputVal;
// 去除空行
const val = inputVal.trim().replace(/\n\s*/g, '\n');
// get input value
const value = TextareaDom.current?.value || '';
const val = value.trim().replace(/\n\s*/g, '\n');
if (!val) {
toast({
@@ -346,7 +335,7 @@ const Chat = ({
isClosable: true
});
resetInputVal(storeInput);
resetInputVal(value);
setChatData((state) => ({
...state,
@@ -355,7 +344,6 @@ const Chat = ({
}
}, [
isChatting,
inputVal,
chatData.history,
setChatData,
resetInputVal,
@@ -485,7 +473,7 @@ const Chat = ({
navigator.vibrate?.(50); // 震动 50 毫秒
if (!isPcDevice) {
if (!isPc) {
PhoneContextShow.current = true;
}
@@ -497,7 +485,7 @@ const Chat = ({
return false;
},
[isPcDevice]
[isPc]
);
// 获取对话信息
@@ -522,6 +510,8 @@ const Chat = ({
status: 'finish'
}))
});
// have records.
if (res.history.length > 0) {
setTimeout(() => {
scrollToBottom('auto');
@@ -599,6 +589,7 @@ const Chat = ({
AiDetail?: boolean;
}) => (
<MenuList fontSize={'sm'} minW={'100px !important'}>
<MenuItem onClick={() => onclickCopy(history.value)}></MenuItem>
{AiDetail && chatData.model.canUse && history.obj === 'AI' && (
<MenuItem
borderBottom={theme.borders.base}
@@ -607,7 +598,6 @@ const Chat = ({
AI助手详情
</MenuItem>
)}
<MenuItem onClick={() => onclickCopy(history.value)}></MenuItem>
{hasVoiceApi && (
<MenuItem
borderBottom={theme.borders.base}
@@ -624,7 +614,6 @@ const Chat = ({
chatData.model.canUse,
chatData.modelId,
delChatRecord,
hasVoiceApi,
onclickCopy,
router,
theme.borders.base
@@ -639,61 +628,9 @@ const Chat = ({
>
{/* pc always show history. */}
{(isPc || !modelId) && (
<Box
position={'relative'}
flex={foldSliderBar ? '0 0 0' : [1, '0 0 250px', '0 0 280px', '0 0 310px', '0 0 340px']}
w={['100%', 0]}
h={'100%'}
zIndex={1}
transition={'0.2s'}
_hover={{
'& > div': { visibility: 'visible', opacity: 1 }
}}
>
<Flex
position={'absolute'}
right={0}
top={'50%'}
transform={'translate(50%,-50%)'}
alignItems={'center'}
justifyContent={'flex-end'}
pr={1}
w={'36px'}
h={'50px'}
borderRadius={'10px'}
bg={'rgba(0,0,0,0.5)'}
cursor={'pointer'}
transition={'0.2s'}
{...(foldSliderBar
? {
opacity: 0.6
}
: {
visibility: 'hidden',
opacity: 0
})}
onClick={() => setFoldSlideBar(!foldSliderBar)}
>
<MyIcon
name={'back'}
transform={foldSliderBar ? 'rotate(180deg)' : ''}
w={'14px'}
color={'white'}
/>
</Flex>
<Box
position={'relative'}
h={'100%'}
bg={'white'}
overflow={foldSliderBar ? 'hidden' : 'visible'}
>
<History
onclickDelHistory={onclickDelHistory}
onclickExportChat={onclickExportChat}
isPcDevice={isPcDevice}
/>
</Box>
</Box>
<SideBar>
<History onclickDelHistory={onclickDelHistory} onclickExportChat={onclickExportChat} />
</SideBar>
)}
{/* 聊天内容 */}
@@ -711,7 +648,7 @@ const Chat = ({
justifyContent={'space-between'}
py={[3, 5]}
px={5}
borderBottom={'1px solid '}
borderBottom={'1px solid'}
borderBottomColor={useColorModeValue('gray.200', 'gray.700')}
color={useColorModeValue('myGray.900', 'white')}
>
@@ -787,7 +724,10 @@ const Chat = ({
order: 1,
mr: ['6px', 2],
cursor: 'pointer',
onClick: () => isPc && router.push(`/model?modelId=${chatData.modelId}`)
onClick: () =>
isPc &&
chatData.model.canUse &&
router.push(`/model?modelId=${chatData.modelId}`)
}
: {
order: 3,
@@ -799,7 +739,7 @@ const Chat = ({
className="avatar"
src={
item.obj === 'Human'
? userInfo?.avatar
? userInfo?.avatar || '/icon/human.png'
: chatData.model.avatar || LOGO_ICON
}
alt="avatar"
@@ -826,6 +766,7 @@ const Chat = ({
<Markdown
source={item.value}
isChatting={isChatting && index === chatData.history.length - 1}
formatLink
/>
{item.systemPrompt && (
<Button
@@ -860,7 +801,9 @@ const Chat = ({
</Flex>
</Flex>
))}
{chatData.history.length === 0 && <Empty model={chatData.model} />}
{chatData.history.length === 0 && (
<Empty model={chatData.model} showChatProblem={true} />
)}
</Box>
</Box>
{/* 发送区 */}
@@ -886,7 +829,6 @@ const Chat = ({
}}
placeholder="提问"
resize={'none'}
value={inputVal}
rows={1}
height={'22px'}
lineHeight={'22px'}
@@ -899,13 +841,12 @@ const Chat = ({
color={useColorModeValue('blackAlpha.700', 'white')}
onChange={(e) => {
const textarea = e.target;
setInputVal(textarea.value);
textarea.style.height = textareaMinH;
textarea.style.height = `${textarea.scrollHeight}px`;
}}
onKeyDown={(e) => {
// 触发快捷发送
if (isPcDevice && e.keyCode === 13 && !e.shiftKey) {
if (isPc && e.keyCode === 13 && !e.shiftKey) {
sendPrompt();
e.preventDefault();
}
@@ -1007,8 +948,7 @@ const Chat = ({
Chat.getInitialProps = ({ query, req }: any) => {
return {
modelId: query?.modelId || '',
chatId: query?.chatId || '',
isPcDevice: !/Mobile/.test(req ? req.headers['user-agent'] : navigator.userAgent)
chatId: query?.chatId || ''
};
};

View File

@@ -31,15 +31,14 @@ import {
ModalHeader
} from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useScreen } from '@/hooks/useScreen';
import { useGlobalStore } from '@/store/global';
import { useQuery } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useCopyData, voiceBroadcast } from '@/utils/tools';
import { useCopyData, voiceBroadcast, hasVoiceApi } from '@/utils/tools';
import { streamFetch } from '@/api/fetch';
import MyIcon from '@/components/Icon';
import { throttle } from 'lodash';
import { Types } from 'mongoose';
import Markdown from '@/components/Markdown';
import { LOGO_ICON } from '@/constants/chat';
import { useChatStore } from '@/store/chat';
import { useLoading } from '@/hooks/useLoading';
@@ -47,30 +46,20 @@ import { fileDownload } from '@/utils/file';
import { htmlTemplate } from '@/constants/common';
import { useUserStore } from '@/store/user';
import Loading from '@/components/Loading';
import Markdown from '@/components/Markdown';
import SideBar from '@/components/SideBar';
import Empty from './components/Empty';
const ShareHistory = dynamic(() => import('./components/ShareHistory'), {
loading: () => <Loading fixed={false} />,
ssr: false
});
const Empty = dynamic(() => import('./components/Empty'), {
loading: () => <Loading fixed={false} />,
ssr: false
});
import styles from './index.module.scss';
const textareaMinH = '22px';
const Chat = ({
shareId,
historyId,
isPcDevice
}: {
shareId: string;
historyId: string;
isPcDevice: boolean;
}) => {
const hasVoiceApi = !!window.speechSynthesis;
const Chat = ({ shareId, historyId }: { shareId: string; historyId: string }) => {
const router = useRouter();
const theme = useTheme();
@@ -91,7 +80,6 @@ const Chat = ({
top: number;
message: ChatSiteItemType;
}>();
const [foldSliderBar, setFoldSlideBar] = useState(false);
const {
password,
@@ -112,7 +100,7 @@ const Chat = ({
const { toast } = useToast();
const { copyData } = useCopyData();
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const { isPc } = useGlobalStore();
const { Loading, setIsLoading } = useLoading();
const { userInfo } = useUserStore();
const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure();
@@ -430,7 +418,7 @@ const Chat = ({
navigator.vibrate?.(50); // 震动 50 毫秒
if (!isPcDevice) {
if (!isPc) {
PhoneContextShow.current = true;
}
@@ -442,7 +430,7 @@ const Chat = ({
return false;
},
[isPcDevice]
[isPc]
);
// 获取对话信息
@@ -454,16 +442,19 @@ const Chat = ({
password
});
const history = shareChatHistory.find((item) => item._id === historyId)?.chats || [];
setShareChatData({
...res,
history: shareChatHistory.find((item) => item._id === historyId)?.chats || []
history
});
onClosePassword();
setTimeout(() => {
scrollToBottom();
}, 500);
history.length > 0 &&
setTimeout(() => {
scrollToBottom();
}, 500);
} catch (e: any) {
toast({
status: 'error',
@@ -533,7 +524,7 @@ const Chat = ({
<MenuItem onClick={() => delShareChatHistoryItemById(historyId, index)}></MenuItem>
</MenuList>
),
[delShareChatHistoryItemById, hasVoiceApi, historyId, onclickCopy, theme.borders.base]
[delShareChatHistoryItemById, historyId, onclickCopy, theme.borders.base]
);
return (
@@ -544,57 +535,13 @@ const Chat = ({
>
{/* pc always show history. */}
{isPc && (
<Box
position={'relative'}
flex={foldSliderBar ? '0 0 0' : [1, '0 0 250px', '0 0 280px', '0 0 310px', '0 0 340px']}
w={['100%', 0]}
h={'100%'}
zIndex={1}
transition={'0.2s'}
_hover={{
'& > div': { visibility: 'visible', opacity: 1 }
}}
>
<Flex
position={'absolute'}
right={0}
top={'50%'}
transform={'translate(50%,-50%)'}
alignItems={'center'}
justifyContent={'flex-end'}
pr={1}
w={'36px'}
h={'50px'}
borderRadius={'10px'}
bg={'rgba(0,0,0,0.5)'}
cursor={'pointer'}
transition={'0.2s'}
{...(foldSliderBar
? {
opacity: 0.6
}
: {
visibility: 'hidden',
opacity: 0
})}
onClick={() => setFoldSlideBar(!foldSliderBar)}
>
<MyIcon
name={'back'}
transform={foldSliderBar ? 'rotate(180deg)' : ''}
w={'14px'}
color={'white'}
/>
</Flex>
<Box position={'relative'} h={'100%'} bg={'white'} overflow={'hidden'}>
<ShareHistory
onclickDelHistory={delShareHistoryById}
onclickExportChat={onclickExportChat}
onCloseSlider={onCloseSlider}
isPcDevice={isPcDevice}
/>
</Box>
</Box>
<SideBar>
<ShareHistory
onclickDelHistory={delShareHistoryById}
onclickExportChat={onclickExportChat}
onCloseSlider={onCloseSlider}
/>
</SideBar>
)}
{/* 聊天内容 */}
@@ -624,13 +571,7 @@ const Chat = ({
onClick={onOpenSlider}
/>
)}
<Box
cursor={'pointer'}
lineHeight={1.2}
textAlign={'center'}
px={3}
fontSize={['sm', 'md']}
>
<Box lineHeight={1.2} textAlign={'center'} px={3} fontSize={['sm', 'md']}>
{shareChatData.model.name}
{shareChatData.history.length > 0 ? ` (${shareChatData.history.length})` : ''}
</Box>
@@ -690,7 +631,7 @@ const Chat = ({
className="avatar"
src={
item.obj === 'Human'
? userInfo?.avatar
? userInfo?.avatar || '/icon/human.png'
: shareChatData.model.avatar || LOGO_ICON
}
alt="avatar"
@@ -717,6 +658,7 @@ const Chat = ({
<Markdown
source={item.value}
isChatting={isChatting && index === shareChatData.history.length - 1}
formatLink
/>
{item.systemPrompt && (
<Button
@@ -751,7 +693,9 @@ const Chat = ({
</Flex>
</Flex>
))}
{shareChatData.history.length === 0 && <Empty model={shareChatData.model} />}
{shareChatData.history.length === 0 && (
<Empty model={shareChatData.model} showChatProblem={false} />
)}
</Box>
</Box>
{/* 发送区 */}
@@ -795,7 +739,7 @@ const Chat = ({
}}
onKeyDown={(e) => {
// 触发快捷发送
if (isPcDevice && e.keyCode === 13 && !e.shiftKey) {
if (isPc && e.keyCode === 13 && !e.shiftKey) {
sendPrompt();
e.preventDefault();
}
@@ -852,7 +796,6 @@ const Chat = ({
onclickDelHistory={delShareHistoryById}
onclickExportChat={onclickExportChat}
onCloseSlider={onCloseSlider}
isPcDevice={isPcDevice}
/>
</DrawerContent>
</Drawer>
@@ -922,8 +865,7 @@ const Chat = ({
Chat.getInitialProps = ({ query, req }: any) => {
return {
shareId: query?.shareId || '',
historyId: query?.historyId || '',
isPcDevice: !/Mobile/.test(req ? req.headers['user-agent'] : navigator.userAgent)
historyId: query?.historyId || ''
};
};

View File

@@ -4,8 +4,8 @@ import Markdown from '@/components/Markdown';
import { useMarkdown } from '@/hooks/useMarkdown';
import { getFilling } from '@/api/system';
import { useQuery } from '@tanstack/react-query';
import { useScreen } from '@/hooks/useScreen';
import { useRouter } from 'next/router';
import { useGlobalStore } from '@/store/global';
import styles from './index.module.scss';
@@ -13,7 +13,7 @@ const Home = () => {
const router = useRouter();
const { inviterId } = router.query as { inviterId: string };
const { data } = useMarkdown({ url: '/intro.md' });
const { isPc } = useScreen();
const { isPc } = useGlobalStore();
useEffect(() => {
if (inviterId) {
@@ -26,112 +26,114 @@ const Home = () => {
/* 加载动画 */
useEffect(() => {
setTimeout(() => {
window.particlesJS?.('particles-js', {
particles: {
number: {
value: 40,
density: {
enable: true,
value_area: 500
}
},
color: {
value: '#4e83fd'
},
shape: {
type: 'circle',
stroke: {
width: 0,
color: '#000000'
},
polygon: {
nb_sides: 5
}
},
opacity: {
value: 0.5,
random: false,
anim: {
enable: false,
speed: 0.1,
opacity_min: 0.1,
sync: false
}
},
size: {
value: 3,
random: true,
anim: {
enable: false,
speed: 10,
size_min: 0.1,
sync: false
}
},
line_linked: {
enable: true,
distance: 150,
color: '#adceff',
opacity: 0.4,
width: 1
},
move: {
enable: true,
speed: 2,
direction: 'none',
random: true,
straight: false,
out_mode: 'out',
bounce: false,
attract: {
enable: false,
rotateX: 600,
rotateY: 1200
}
}
},
interactivity: {
detect_on: 'canvas',
events: {
onhover: {
enable: true,
mode: 'grab'
},
onclick: {
enable: true,
mode: 'push'
},
resize: true
},
modes: {
grab: {
distance: 140,
line_linked: {
opacity: 1
try {
window.particlesJS?.('particles-js', {
particles: {
number: {
value: 40,
density: {
enable: true,
value_area: 500
}
},
bubble: {
distance: 400,
size: 40,
duration: 2,
opacity: 8,
speed: 3
color: {
value: '#4e83fd'
},
repulse: {
distance: 200,
duration: 0.4
shape: {
type: 'circle',
stroke: {
width: 0,
color: '#000000'
},
polygon: {
nb_sides: 5
}
},
push: {
particles_nb: 4
opacity: {
value: 0.5,
random: false,
anim: {
enable: false,
speed: 0.1,
opacity_min: 0.1,
sync: false
}
},
remove: {
particles_nb: 2
size: {
value: 3,
random: true,
anim: {
enable: false,
speed: 10,
size_min: 0.1,
sync: false
}
},
line_linked: {
enable: true,
distance: 150,
color: '#adceff',
opacity: 0.4,
width: 1
},
move: {
enable: true,
speed: 2,
direction: 'none',
random: true,
straight: false,
out_mode: 'out',
bounce: false,
attract: {
enable: false,
rotateX: 600,
rotateY: 1200
}
}
}
},
retina_detect: true
});
}, 1000);
},
interactivity: {
detect_on: 'canvas',
events: {
onhover: {
enable: true,
mode: 'grab'
},
onclick: {
enable: true,
mode: 'push'
},
resize: true
},
modes: {
grab: {
distance: 140,
line_linked: {
opacity: 1
}
},
bubble: {
distance: 400,
size: 40,
duration: 2,
opacity: 8,
speed: 3
},
repulse: {
distance: 200,
duration: 0.4
},
push: {
particles_nb: 4
},
remove: {
particles_nb: 2
}
}
},
retina_detect: true
});
} catch (error) {}
}, 500);
}, [isPc]);
return (

View File

@@ -0,0 +1,305 @@
import React, { useCallback, useState, useRef } from 'react';
import {
Box,
TableContainer,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
IconButton,
Flex,
Button,
useDisclosure,
Menu,
MenuButton,
MenuList,
MenuItem,
Input,
Tooltip
} from '@chakra-ui/react';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import type { BoxProps } from '@chakra-ui/react';
import type { KbDataItemType } from '@/types/plugin';
import { ModelDataStatusMap } from '@/constants/model';
import { usePagination } from '@/hooks/usePagination';
import {
getKbDataList,
getExportDataList,
delOneKbDataByDataId,
getTrainingData
} from '@/api/plugins/kb';
import { DeleteIcon, RepeatIcon, EditIcon } from '@chakra-ui/icons';
import { useLoading } from '@/hooks/useLoading';
import { fileDownload } from '@/utils/file';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import Papa from 'papaparse';
import dynamic from 'next/dynamic';
import InputModal, { FormData as InputDataType } from './InputDataModal';
const SelectFileModal = dynamic(() => import('./SelectFileModal'));
const SelectCsvModal = dynamic(() => import('./SelectCsvModal'));
const DataCard = ({ kbId }: { kbId: string }) => {
const lastSearch = useRef('');
const tdStyles = useRef<BoxProps>({
fontSize: 'xs',
minW: '150px',
maxW: '500px',
maxH: '250px',
whiteSpace: 'pre-wrap',
overflowY: 'auto'
});
const [searchText, setSearchText] = useState('');
const { Loading, setIsLoading } = useLoading();
const { toast } = useToast();
const {
data: modelDataList,
isLoading,
Pagination,
total,
getData,
pageNum
} = usePagination<KbDataItemType>({
api: getKbDataList,
pageSize: 10,
params: {
kbId,
searchText
},
defaultRequest: false
});
useQuery(['getKbData', kbId], () => {
getData(1);
return null;
});
const [editInputData, setEditInputData] = useState<InputDataType>();
const {
isOpen: isOpenSelectFileModal,
onOpen: onOpenSelectFileModal,
onClose: onCloseSelectFileModal
} = useDisclosure();
const {
isOpen: isOpenSelectCsvModal,
onOpen: onOpenSelectCsvModal,
onClose: onCloseSelectCsvModal
} = useDisclosure();
const { data: { splitDataQueue = 0, embeddingQueue = 0 } = {}, refetch } = useQuery(
['getModelSplitDataList'],
() => getTrainingData(kbId),
{
onError(err) {
console.log(err);
}
}
);
const refetchData = useCallback(
(num = 1) => {
getData(num);
refetch();
return null;
},
[getData, refetch]
);
// interval get data
useQuery(['refetchData'], () => refetchData(pageNum), {
refetchInterval: 5000,
enabled: splitDataQueue > 0 || embeddingQueue > 0
});
// get al data and export csv
const { mutate: onclickExport, isLoading: isLoadingExport = false } = useMutation({
mutationFn: () => getExportDataList(kbId),
onSuccess(res) {
try {
setIsLoading(true);
const text = Papa.unparse({
fields: ['question', 'answer'],
data: res
});
fileDownload({
text,
type: 'text/csv',
filename: 'data.csv'
});
} catch (error) {
error;
}
setIsLoading(false);
},
onError(err: any) {
toast({
title: typeof err === 'string' ? err : err?.message || '导出异常',
status: 'error'
});
console.log(err);
}
});
return (
<Box position={'relative'}>
<Flex>
<Box fontWeight={'bold'} fontSize={'lg'} flex={1} mr={2}>
: {total}
</Box>
<IconButton
icon={<RepeatIcon />}
aria-label={'refresh'}
variant={'outline'}
mr={[2, 4]}
size={'sm'}
onClick={() => refetchData(pageNum)}
/>
<Button
variant={'outline'}
mr={2}
size={'sm'}
isLoading={isLoadingExport}
title={'换行数据导出时,会进行格式转换'}
onClick={() => onclickExport()}
>
</Button>
<Menu autoSelect={false}>
<MenuButton as={Button} size={'sm'}>
</MenuButton>
<MenuList>
<MenuItem
onClick={() =>
setEditInputData({
a: '',
q: ''
})
}
>
</MenuItem>
<MenuItem onClick={onOpenSelectFileModal}>/</MenuItem>
<MenuItem onClick={onOpenSelectCsvModal}>csv </MenuItem>
</MenuList>
</Menu>
</Flex>
<Flex mt={4}>
{(splitDataQueue > 0 || embeddingQueue > 0) && (
<Box fontSize={'xs'}>
{splitDataQueue > 0 ? `${splitDataQueue}条数据正在拆分,` : ''}
{embeddingQueue > 0 ? `${embeddingQueue}条数据正在生成索引,` : ''}
...
</Box>
)}
<Box flex={1} />
<Input
maxW={'240px'}
size={'sm'}
value={searchText}
placeholder="搜索相关问题和答案,回车确认"
onChange={(e) => setSearchText(e.target.value)}
onBlur={() => {
if (searchText === lastSearch.current) return;
getData(1);
lastSearch.current = searchText;
}}
onKeyDown={(e) => {
if (searchText === lastSearch.current) return;
if (e.key === 'Enter') {
getData(1);
lastSearch.current = searchText;
}
}}
/>
</Flex>
<TableContainer mt={4} minH={'200px'}>
<Table>
<Thead>
<Tr>
<Th>
<Tooltip
label={
'对话时,会将用户的问题和知识库的 "匹配知识点" 进行比较,找到最相似的前 n 条记录,将这些记录的 "匹配知识点"+"补充知识点" 作为 chatgpt 的系统提示词。'
}
>
<QuestionOutlineIcon ml={1} />
</Tooltip>
</Th>
<Th></Th>
<Th></Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{modelDataList.map((item) => (
<Tr key={item.id}>
<Td>
<Box {...tdStyles.current}>{item.q}</Box>
</Td>
<Td>
<Box {...tdStyles.current}>{item.a || '-'}</Box>
</Td>
<Td>{ModelDataStatusMap[item.status]}</Td>
<Td>
<IconButton
mr={5}
icon={<EditIcon />}
variant={'outline'}
aria-label={'delete'}
size={'sm'}
onClick={() =>
setEditInputData({
dataId: item.id,
q: item.q,
a: item.a
})
}
/>
<IconButton
icon={<DeleteIcon />}
variant={'outline'}
colorScheme={'gray'}
aria-label={'delete'}
size={'sm'}
onClick={async () => {
await delOneKbDataByDataId(item.id);
refetchData(pageNum);
}}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
<Flex mt={2} justifyContent={'flex-end'}>
<Pagination />
</Flex>
<Loading loading={isLoading} fixed={false} />
{editInputData !== undefined && (
<InputModal
kbId={kbId}
defaultValues={editInputData}
onClose={() => setEditInputData(undefined)}
onSuccess={refetchData}
/>
)}
{isOpenSelectFileModal && (
<SelectFileModal kbId={kbId} onClose={onCloseSelectFileModal} onSuccess={refetchData} />
)}
{isOpenSelectCsvModal && (
<SelectCsvModal kbId={kbId} onClose={onCloseSelectCsvModal} onSuccess={refetchData} />
)}
</Box>
);
};
export default DataCard;

View File

@@ -0,0 +1,245 @@
import React, { useCallback, useState, useRef } from 'react';
import { useRouter } from 'next/router';
import {
Card,
Box,
Flex,
Button,
Tooltip,
Image,
FormControl,
Input,
Tag,
IconButton
} from '@chakra-ui/react';
import { QuestionOutlineIcon, DeleteIcon } from '@chakra-ui/icons';
import { useToast } from '@/hooks/useToast';
import { useForm } from 'react-hook-form';
import { useQuery } from '@tanstack/react-query';
import { useUserStore } from '@/store/user';
import { delKbById, putKbById } from '@/api/plugins/kb';
import { useLoading } from '@/hooks/useLoading';
import { KbItemType } from '@/types/plugin';
import { useSelectFile } from '@/hooks/useSelectFile';
import { useConfirm } from '@/hooks/useConfirm';
import { compressImg } from '@/utils/file';
import DataCard from './DataCard';
import { getErrText } from '@/utils/tools';
const Detail = ({ kbId }: { kbId: string }) => {
const { toast } = useToast();
const router = useRouter();
const InputRef = useRef<HTMLInputElement>(null);
const { setLastKbId, kbDetail, getKbDetail, loadKbList, myKbList } = useUserStore();
const [btnLoading, setBtnLoading] = useState(false);
const [refresh, setRefresh] = useState(false);
const { getValues, formState, setValue, reset, register, handleSubmit } = useForm<KbItemType>({
defaultValues: kbDetail
});
const { openConfirm, ConfirmChild } = useConfirm({
content: '确认删除该知识库?数据将无法恢复,请确认!'
});
const { File, onOpen: onOpenSelectFile } = useSelectFile({
fileType: '.jpg,.png',
multiple: false
});
useQuery([kbId, myKbList], () => getKbDetail(kbId), {
onSuccess(res) {
kbId && setLastKbId(kbId);
if (res) {
reset(res);
if (InputRef.current) {
InputRef.current.value = res.tags;
}
}
},
onError(err: any) {
toast({
title: getErrText(err, '获取AI助手异常'),
status: 'error'
});
loadKbList(true);
setLastKbId('');
router.replace(`/kb?kbId=${myKbList[0]?._id || ''}`);
}
});
/* 点击删除 */
const onclickDelKb = useCallback(async () => {
setBtnLoading(true);
try {
await delKbById(kbId);
toast({
title: '删除成功',
status: 'success'
});
router.replace(`/kb?kbId=${myKbList.find((item) => item._id !== kbId)?._id || ''}`);
await loadKbList(true);
} catch (err: any) {
toast({
title: err?.message || '删除失败',
status: 'error'
});
}
setBtnLoading(false);
}, [setBtnLoading, kbId, toast, router, myKbList, loadKbList]);
const saveSubmitSuccess = useCallback(
async (data: KbItemType) => {
setBtnLoading(true);
try {
await putKbById({
id: kbId,
...data
});
toast({
title: '更新成功',
status: 'success'
});
loadKbList(true);
} catch (err: any) {
toast({
title: err?.message || '更新失败',
status: 'error'
});
}
setBtnLoading(false);
},
[kbId, loadKbList, toast]
);
const saveSubmitError = useCallback(() => {
// deep search message
const deepSearch = (obj: any): string => {
if (!obj) return '提交表单错误';
if (!!obj.message) {
return obj.message;
}
return deepSearch(Object.values(obj)[0]);
};
toast({
title: deepSearch(formState.errors),
status: 'error',
duration: 4000,
isClosable: true
});
}, [formState.errors, toast]);
const onSelectFile = useCallback(
async (e: File[]) => {
const file = e[0];
if (!file) return;
try {
const base64 = await compressImg({
file,
maxW: 100,
maxH: 100
});
setValue('avatar', base64);
loadKbList(true);
} catch (err: any) {
toast({
title: typeof err === 'string' ? err : '头像选择异常',
status: 'warning'
});
}
},
[loadKbList, setValue, toast]
);
return (
<Box h={'100%'} p={5} overflow={'overlay'} position={'relative'}>
<Card p={6}>
<Flex>
<Box fontWeight={'bold'} fontSize={'2xl'} flex={1}>
</Box>
{kbDetail._id && (
<>
<Button
isLoading={btnLoading}
mr={3}
onClick={handleSubmit(saveSubmitSuccess, saveSubmitError)}
>
</Button>
<IconButton
isLoading={btnLoading}
icon={<DeleteIcon />}
aria-label={''}
variant={'solid'}
colorScheme={'red'}
onClick={openConfirm(onclickDelKb)}
/>
</>
)}
</Flex>
<Flex mt={5} alignItems={'center'}>
<Box flex={'0 0 60px'} w={0}>
</Box>
<Image
src={getValues('avatar') || '/icon/logo.png'}
alt={'avatar'}
w={['28px', '36px']}
h={['28px', '36px']}
objectFit={'cover'}
cursor={'pointer'}
title={'点击切换头像'}
onClick={onOpenSelectFile}
/>
</Flex>
<FormControl mt={5}>
<Flex alignItems={'center'} maxW={'350px'}>
<Box flex={'0 0 60px'} w={0}>
</Box>
<Input
{...register('name', {
required: '知识库名称不能为空'
})}
/>
</Flex>
</FormControl>
<Box>
<Flex mt={5} alignItems={'center'} maxW={'350px'} flexWrap={'wrap'}>
<Box flex={'0 0 60px'} w={0}>
<Tooltip label={'仅用于记忆,用空格隔开多个标签'}>
<QuestionOutlineIcon ml={1} />
</Tooltip>
</Box>
<Input
flex={1}
ref={InputRef}
placeholder={'标签,使用空格分割。'}
onChange={(e) => {
setValue('tags', e.target.value);
setRefresh(!refresh);
}}
/>
<Box pl={'60px'} mt={2} w="100%">
{getValues('tags')
.split(' ')
.filter((item) => item)
.map((item, i) => (
<Tag mr={2} mb={2} key={i} variant={'outline'} colorScheme={'blue'}>
{item}
</Tag>
))}
</Box>
</Flex>
</Box>
</Card>
<Card p={6} mt={5}>
<DataCard kbId={kbId} />
</Card>
<File onSelect={onSelectFile} />
<ConfirmChild />
</Box>
);
};
export default Detail;

View File

@@ -11,17 +11,15 @@ import {
Textarea
} from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { postModelDataInput, putModelDataById } from '@/api/model';
import { postKbDataFromList, putKbDataById } from '@/api/plugins/kb';
import { useToast } from '@/hooks/useToast';
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 12);
export type FormData = { dataId?: string; a: string; q: string };
const InputDataModal = ({
onClose,
onSuccess,
modelId,
kbId,
defaultValues = {
a: '',
q: ''
@@ -29,7 +27,7 @@ const InputDataModal = ({
}: {
onClose: () => void;
onSuccess: () => void;
modelId: string;
kbId: string;
defaultValues?: FormData;
}) => {
const [importing, setImporting] = useState(false);
@@ -54,8 +52,8 @@ const InputDataModal = ({
setImporting(true);
try {
const res = await postModelDataInput({
modelId: modelId,
const res = await postKbDataFromList({
kbId,
data: [
{
a: e.a,
@@ -65,8 +63,8 @@ const InputDataModal = ({
});
toast({
title: res === 0 ? '导入数据成功,需要一段时间训练' : '数据导入异常',
status: res === 0 ? 'success' : 'warning'
title: res === 0 ? '可能已存在完全一致的数据' : '导入数据成功,需要一段时间训练',
status: 'success'
});
reset({
a: '',
@@ -82,7 +80,7 @@ const InputDataModal = ({
}
setImporting(false);
},
[modelId, onSuccess, reset, toast]
[kbId, onSuccess, reset, toast]
);
const updateData = useCallback(
@@ -90,7 +88,7 @@ const InputDataModal = ({
if (!e.dataId) return;
if (e.a !== defaultValues.a || e.q !== defaultValues.q) {
await putModelDataById({
await putKbDataById({
dataId: e.dataId,
a: e.a,
q: e.q === defaultValues.q ? '' : e.q

View File

@@ -0,0 +1,155 @@
import React, { useCallback, useState, useMemo } from 'react';
import { Box, Flex, useTheme, Input, IconButton, Tooltip, Image, Tag } from '@chakra-ui/react';
import { AddIcon } from '@chakra-ui/icons';
import { useRouter } from 'next/router';
import { postCreateKb } from '@/api/plugins/kb';
import { useLoading } from '@/hooks/useLoading';
import { useToast } from '@/hooks/useToast';
import { useQuery } from '@tanstack/react-query';
import { useUserStore } from '@/store/user';
import MyIcon from '@/components/Icon';
const KbList = ({ kbId }: { kbId: string }) => {
const theme = useTheme();
const router = useRouter();
const { toast } = useToast();
const { Loading, setIsLoading } = useLoading();
const { myKbList, loadKbList } = useUserStore();
const [searchText, setSearchText] = useState('');
const kbs = useMemo(
() => myKbList.filter((item) => new RegExp(searchText, 'ig').test(item.name + item.tags)),
[myKbList, searchText]
);
/* 加载模型 */
const { isLoading } = useQuery(['loadModels'], () => loadKbList(false));
const handleCreateModel = useCallback(async () => {
setIsLoading(true);
try {
const name = `知识库${myKbList.length + 1}`;
const id = await postCreateKb({ name });
await loadKbList(true);
toast({
title: '创建成功',
status: 'success'
});
router.replace(`/kb?kbId=${id}`);
} catch (err: any) {
toast({
title: typeof err === 'string' ? err : err.message || '出现了意外',
status: 'error'
});
}
setIsLoading(false);
}, [loadKbList, myKbList.length, router, setIsLoading, toast]);
return (
<Flex
position={'relative'}
flexDirection={'column'}
w={'100%'}
h={'100%'}
bg={'white'}
borderRight={['', theme.borders.base]}
>
<Flex w={'90%'} my={5} mx={'auto'}>
<Flex flex={1} mr={2} position={'relative'} alignItems={'center'}>
<Input
h={'32px'}
placeholder="搜索知识库"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{searchText && (
<MyIcon
zIndex={10}
position={'absolute'}
right={3}
name={'closeSolid'}
w={'16px'}
h={'16px'}
color={'myGray.500'}
cursor={'pointer'}
onClick={() => setSearchText('')}
/>
)}
</Flex>
<Tooltip label={'新建一个知识库'}>
<IconButton
h={'32px'}
icon={<AddIcon />}
aria-label={''}
variant={'outline'}
onClick={handleCreateModel}
/>
</Tooltip>
</Flex>
<Box flex={'1 0 0'} h={0} overflow={'overlay'}>
{kbs.map((item) => (
<Flex
key={item._id}
position={'relative'}
alignItems={['flex-start', 'center']}
p={3}
mb={[2, 0]}
cursor={'pointer'}
transition={'background-color .2s ease-in'}
borderLeft={['', '5px solid transparent']}
_hover={{
backgroundColor: ['', '#dee0e3']
}}
{...(kbId === item._id
? {
backgroundColor: '#eff0f1',
borderLeftColor: 'myBlue.600 !important'
}
: {})}
onClick={() => {
if (item._id === kbId) return;
router.push(`/kb?kbId=${item._id}`);
}}
>
<Image
src={item.avatar || '/icon/logo.png'}
alt=""
w={'34px'}
maxH={'50px'}
objectFit={'contain'}
/>
<Box flex={'1 0 0'} w={0} ml={3}>
<Box className="textEllipsis" color={'myGray.1000'}>
{item.name}
</Box>
{/* tags */}
<Box className="textEllipsis" color={'myGray.400'} mt={1} fontSize={'sm'}>
{!item.tags ? (
<>{item.tags || '你还没设置标签~'}</>
) : (
item.tags.split(' ').map((item, i) => (
<Tag key={i} mr={2} mb={2} variant={'outline'} colorScheme={'blue'} size={'sm'}>
{item}
</Tag>
))
)}
</Box>
</Box>
</Flex>
))}
{!isLoading && myKbList.length === 0 && (
<Flex h={'100%'} flexDirection={'column'} alignItems={'center'} pt={'30vh'}>
<MyIcon name="empty" w={'48px'} h={'48px'} color={'transparent'} />
<Box mt={2} color={'myGray.500'}>
~
</Box>
</Flex>
)}
</Box>
<Loading loading={isLoading} fixed={false} />
</Flex>
);
};
export default KbList;

View File

@@ -15,7 +15,7 @@ import { useSelectFile } from '@/hooks/useSelectFile';
import { useConfirm } from '@/hooks/useConfirm';
import { readCsvContent } from '@/utils/file';
import { useMutation } from '@tanstack/react-query';
import { postModelDataCsvData } from '@/api/model';
import { postKbDataFromList } from '@/api/plugins/kb';
import Markdown from '@/components/Markdown';
import { useMarkdown } from '@/hooks/useMarkdown';
import { fileDownload } from '@/utils/file';
@@ -25,16 +25,16 @@ const csvTemplate = `question,answer\n"什么是 laf","laf 是一个云函数开
const SelectJsonModal = ({
onClose,
onSuccess,
modelId
kbId
}: {
onClose: () => void;
onSuccess: () => void;
modelId: string;
kbId: string;
}) => {
const [selecting, setSelecting] = useState(false);
const { toast } = useToast();
const { File, onOpen } = useSelectFile({ fileType: '.csv', multiple: false });
const [fileData, setFileData] = useState<string[][]>([]);
const [fileData, setFileData] = useState<{ q: string; a: string }[]>([]);
const { openConfirm, ConfirmChild } = useConfirm({
content: '确认导入该数据集?'
});
@@ -48,7 +48,12 @@ const SelectJsonModal = ({
if (header[0] !== 'question' || header[1] !== 'answer') {
throw new Error('csv 文件格式有误');
}
setFileData(data);
setFileData(
data.map((item) => ({
q: item[0] || '',
a: item[1] || ''
}))
);
} catch (error: any) {
console.log(error);
toast({
@@ -63,8 +68,13 @@ const SelectJsonModal = ({
const { mutate, isLoading } = useMutation({
mutationFn: async () => {
if (!fileData) return;
const res = await postModelDataCsvData(modelId, fileData);
if (!fileData || fileData.length === 0) return;
const res = await postKbDataFromList({
kbId,
data: fileData
});
toast({
title: `导入数据成功,最终导入: ${res || 0} 条数据。需要一段时间训练`,
status: 'success',
@@ -120,10 +130,10 @@ const SelectJsonModal = ({
{fileData.map((item, index) => (
<Box key={index}>
<Box>
Q{index + 1}. {item[0]}
Q{index + 1}. {item.q}
</Box>
<Box>
A{index + 1}. {item[1]}
A{index + 1}. {item.a}
</Box>
</Box>
))}

View File

@@ -17,10 +17,10 @@ import { useSelectFile } from '@/hooks/useSelectFile';
import { useConfirm } from '@/hooks/useConfirm';
import { readTxtContent, readPdfContent, readDocContent } from '@/utils/file';
import { useMutation } from '@tanstack/react-query';
import { postModelDataSplitData } from '@/api/model';
import { formatPrice } from '@/utils/user';
import { postSplitData } from '@/api/plugins/kb';
import Radio from '@/components/Radio';
import { splitText_token } from '@/utils/file';
import { SplitTextTypEnum } from '@/constants/plugin';
const fileExtension = '.txt,.doc,.docx,.pdf,.md';
@@ -42,17 +42,17 @@ const modeMap = {
const SelectFileModal = ({
onClose,
onSuccess,
modelId
kbId
}: {
onClose: () => void;
onSuccess: () => void;
modelId: string;
kbId: string;
}) => {
const [btnLoading, setBtnLoading] = useState(false);
const { toast } = useToast();
const [prompt, setPrompt] = useState('');
const { File, onOpen } = useSelectFile({ fileType: fileExtension, multiple: true });
const [mode, setMode] = useState<'qa' | 'subsection'>('qa');
const [mode, setMode] = useState<`${SplitTextTypEnum}`>(SplitTextTypEnum.subsection);
const [fileTextArr, setFileTextArr] = useState<string[]>(['']);
const [splitRes, setSplitRes] = useState<{ tokens: number; chunks: string[] }>({
tokens: 0,
@@ -107,8 +107,8 @@ const SelectFileModal = ({
mutationFn: async () => {
if (splitRes.chunks.length === 0) return;
await postModelDataSplitData({
modelId,
await postSplitData({
kbId,
chunks: splitRes.chunks,
prompt: `下面是"${prompt || '一段长文本'}"`,
mode

43
src/pages/kb/index.tsx Normal file
View File

@@ -0,0 +1,43 @@
import React, { useEffect } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import { useGlobalStore } from '@/store/global';
import { useRouter } from 'next/router';
import { useUserStore } from '@/store/user';
import SideBar from '@/components/SideBar';
import KbList from './components/KbList';
import KbDetail from './components/Detail';
const Kb = ({ kbId }: { kbId: string }) => {
const router = useRouter();
const { isPc } = useGlobalStore();
const { lastKbId } = useUserStore();
// redirect
useEffect(() => {
if (isPc && !kbId && lastKbId) {
router.replace(`/kb?kbId=${lastKbId}`);
}
}, [isPc, kbId, lastKbId, router]);
return (
<Flex h={'100%'} position={'relative'} overflow={'hidden'}>
{/* 模型列表 */}
{(isPc || !kbId) && (
<SideBar w={['100%', '0 0 250px', '0 0 270px', '0 0 290px']}>
<KbList kbId={kbId} />
</SideBar>
)}
<Box flex={'1 0 0'} w={0} h={'100%'} position={'relative'}>
{kbId && <KbDetail kbId={kbId} />}
</Box>
</Flex>
);
};
export default Kb;
Kb.getInitialProps = ({ query, req }: any) => {
return {
kbId: query?.kbId || ''
};
};

View File

@@ -5,7 +5,6 @@ import { PageTypeEnum } from '../../../constants/user';
import { postFindPassword } from '@/api/user';
import { useSendCode } from '@/hooks/useSendCode';
import type { ResLogin } from '@/api/response/user';
import { useScreen } from '@/hooks/useScreen';
import { useToast } from '@/hooks/useToast';
interface Props {
@@ -22,7 +21,6 @@ interface RegisterType {
const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
const { toast } = useToast();
const { mediaLgMd } = useScreen();
const {
register,
handleSubmit,
@@ -81,7 +79,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
<FormControl mt={5} isInvalid={!!errors.username}>
<Input
placeholder="邮箱/手机号"
size={mediaLgMd}
size={['md', 'lg']}
{...register('username', {
required: '邮箱/手机号不能为空',
pattern: {
@@ -100,7 +98,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
<Input
flex={1}
placeholder="验证码"
size={mediaLgMd}
size={['md', 'lg']}
{...register('code', {
required: '验证码不能为空'
})}
@@ -109,7 +107,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
ml={5}
w={'145px'}
maxW={'50%'}
size={mediaLgMd}
size={['md', 'lg']}
onClick={onclickSendCode}
isDisabled={codeCountDown > 0}
isLoading={codeSending}
@@ -125,7 +123,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
<Input
type={'password'}
placeholder="新密码"
size={mediaLgMd}
size={['md', 'lg']}
{...register('password', {
required: '密码不能为空',
minLength: {
@@ -146,7 +144,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
<Input
type={'password'}
placeholder="确认密码"
size={mediaLgMd}
size={['md', 'lg']}
{...register('password2', {
validate: (val) => (getValues('password') === val ? true : '两次密码不一致')
})}
@@ -170,7 +168,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
type="submit"
mt={5}
w={'100%'}
size={mediaLgMd}
size={['md', 'lg']}
colorScheme="blue"
isLoading={requesting}
>

View File

@@ -5,7 +5,6 @@ import { PageTypeEnum } from '@/constants/user';
import { postLogin } from '@/api/user';
import type { ResLogin } from '@/api/response/user';
import { useToast } from '@/hooks/useToast';
import { useScreen } from '@/hooks/useScreen';
interface Props {
setPageType: Dispatch<`${PageTypeEnum}`>;
@@ -19,7 +18,6 @@ interface LoginFormType {
const LoginForm = ({ setPageType, loginSuccess }: Props) => {
const { toast } = useToast();
const { mediaLgMd } = useScreen();
const {
register,
handleSubmit,
@@ -62,7 +60,7 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => {
<FormControl mt={8} isInvalid={!!errors.username}>
<Input
placeholder="邮箱/手机号"
size={mediaLgMd}
size={['md', 'lg']}
{...register('username', {
required: '邮箱/手机号不能为空',
pattern: {
@@ -79,7 +77,7 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => {
<FormControl mt={8} isInvalid={!!errors.password}>
<Input
type={'password'}
size={mediaLgMd}
size={['md', 'lg']}
placeholder="密码"
{...register('password', {
required: '密码不能为空',
@@ -119,7 +117,7 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => {
type="submit"
mt={8}
w={'100%'}
size={mediaLgMd}
size={['md', 'lg']}
colorScheme="blue"
isLoading={requesting}
>

View File

@@ -5,7 +5,6 @@ import { PageTypeEnum } from '@/constants/user';
import { postRegister } from '@/api/user';
import { useSendCode } from '@/hooks/useSendCode';
import type { ResLogin } from '@/api/response/user';
import { useScreen } from '@/hooks/useScreen';
import { useToast } from '@/hooks/useToast';
import { useRouter } from 'next/router';
import { postCreateModel } from '@/api/model';
@@ -25,7 +24,6 @@ interface RegisterType {
const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
const { inviterId = '' } = useRouter().query as { inviterId: string };
const { toast } = useToast();
const { mediaLgMd } = useScreen();
const {
register,
handleSubmit,
@@ -89,7 +87,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
<FormControl mt={5} isInvalid={!!errors.username}>
<Input
placeholder="邮箱/手机号"
size={mediaLgMd}
size={['md', 'lg']}
{...register('username', {
required: '邮箱/手机号不能为空',
pattern: {
@@ -107,7 +105,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
<Flex>
<Input
flex={1}
size={mediaLgMd}
size={['md', 'lg']}
placeholder="验证码"
{...register('code', {
required: '验证码不能为空'
@@ -117,7 +115,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
ml={5}
w={'145px'}
maxW={'50%'}
size={mediaLgMd}
size={['md', 'lg']}
onClick={onclickSendCode}
isDisabled={codeCountDown > 0}
isLoading={codeSending}
@@ -133,7 +131,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
<Input
type={'password'}
placeholder="密码"
size={mediaLgMd}
size={['md', 'lg']}
{...register('password', {
required: '密码不能为空',
minLength: {
@@ -154,7 +152,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
<Input
type={'password'}
placeholder="确认密码"
size={mediaLgMd}
size={['md', 'lg']}
{...register('password2', {
validate: (val) => (getValues('password') === val ? true : '两次密码不一致')
})}
@@ -178,7 +176,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
type="submit"
mt={5}
w={'100%'}
size={mediaLgMd}
size={['md', 'lg']}
colorScheme="blue"
isLoading={requesting}
>

View File

@@ -2,7 +2,7 @@ import React, { useState, useCallback, useEffect } from 'react';
import styles from './index.module.scss';
import { Box, Flex, Image } from '@chakra-ui/react';
import { PageTypeEnum } from '@/constants/user';
import { useScreen } from '@/hooks/useScreen';
import { useGlobalStore } from '@/store/global';
import type { ResLogin } from '@/api/response/user';
import { useRouter } from 'next/router';
import { useUserStore } from '@/store/user';
@@ -12,10 +12,10 @@ import dynamic from 'next/dynamic';
const RegisterForm = dynamic(() => import('./components/RegisterForm'));
const ForgetPasswordForm = dynamic(() => import('./components/ForgetPasswordForm'));
const Login = ({ isPcDevice }: { isPcDevice: boolean }) => {
const Login = () => {
const router = useRouter();
const { lastRoute = '' } = router.query as { lastRoute: string };
const { isPc } = useScreen({ defaultIsPc: isPcDevice });
const { isPc } = useGlobalStore();
const [pageType, setPageType] = useState<`${PageTypeEnum}`>(PageTypeEnum.login);
const { setUserInfo, setLastModelId, loadMyModels } = useUserStore();
const { setLastChatId, setLastChatModelId, loadHistory } = useChatStore();
@@ -114,9 +114,3 @@ const Login = ({ isPcDevice }: { isPcDevice: boolean }) => {
};
export default Login;
Login.getInitialProps = ({ query, req }: any) => {
return {
isPcDevice: !/Mobile/.test(req ? req.headers['user-agent'] : navigator.userAgent)
};
};

View File

@@ -126,7 +126,7 @@ const ModelList = ({ modelId }: { modelId: string }) => {
{...(modelId === item._id
? {
backgroundColor: '#eff0f1',
borderLeftColor: 'myBlue.600'
borderLeftColor: 'myBlue.600 !important'
}
: {})}
onClick={() => {

View File

@@ -1,310 +0,0 @@
import React, { useCallback, useState, useRef, useEffect } from 'react';
import {
Box,
TableContainer,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
IconButton,
Flex,
Button,
useDisclosure,
Menu,
MenuButton,
MenuList,
MenuItem,
Input,
Tooltip
} from '@chakra-ui/react';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import type { BoxProps } from '@chakra-ui/react';
import type { ModelDataItemType } from '@/types/model';
import { ModelDataStatusMap } from '@/constants/model';
import { usePagination } from '@/hooks/usePagination';
import {
getModelDataList,
delOneModelData,
getModelSplitDataListLen,
getExportDataList
} from '@/api/model';
import { DeleteIcon, RepeatIcon, EditIcon } from '@chakra-ui/icons';
import { useLoading } from '@/hooks/useLoading';
import { fileDownload } from '@/utils/file';
import dynamic from 'next/dynamic';
import { useMutation, useQuery } from '@tanstack/react-query';
import Papa from 'papaparse';
import InputModal, { FormData as InputDataType } from './InputDataModal';
const SelectFileModal = dynamic(() => import('./SelectFileModal'));
const SelectCsvModal = dynamic(() => import('./SelectCsvModal'));
const ModelDataCard = ({ modelId, isOwner }: { modelId: string; isOwner: boolean }) => {
const { Loading, setIsLoading } = useLoading();
const lastSearch = useRef('');
const [searchText, setSearchText] = useState('');
const tdStyles = useRef<BoxProps>({
fontSize: 'xs',
minW: '150px',
maxW: '500px',
maxH: '250px',
whiteSpace: 'pre-wrap',
overflowY: 'auto'
});
const {
data: modelDataList,
isLoading,
Pagination,
total,
getData,
pageNum
} = usePagination<ModelDataItemType>({
api: getModelDataList,
pageSize: 10,
params: {
modelId,
searchText
},
defaultRequest: false
});
useEffect(() => {
getData(1);
}, [modelId, getData]);
const [editInputData, setEditInputData] = useState<InputDataType>();
const {
isOpen: isOpenSelectFileModal,
onOpen: onOpenSelectFileModal,
onClose: onCloseSelectFileModal
} = useDisclosure();
const {
isOpen: isOpenSelectCsvModal,
onOpen: onOpenSelectCsvModal,
onClose: onCloseSelectCsvModal
} = useDisclosure();
const { data: { splitDataQueue = 0, embeddingQueue = 0 } = {}, refetch } = useQuery(
['getModelSplitDataList'],
() => getModelSplitDataListLen(modelId),
{
onError(err) {
console.log(err);
}
}
);
const refetchData = useCallback(
(num = 1) => {
getData(num);
refetch();
return null;
},
[getData, refetch]
);
useQuery(['refetchData'], () => refetchData(pageNum), {
refetchInterval: 5000,
enabled: splitDataQueue > 0 || embeddingQueue > 0
});
// 获取所有的数据,并导出 json
const { mutate: onclickExport, isLoading: isLoadingExport = false } = useMutation({
mutationFn: () => getExportDataList(modelId),
onSuccess(res) {
try {
setIsLoading(true);
const text = Papa.unparse({
fields: ['question', 'answer'],
data: res
});
fileDownload({
text,
type: 'text/csv',
filename: 'data.csv'
});
} catch (error) {
error;
}
setIsLoading(false);
},
onError(err) {
console.log(err);
}
});
return (
<Box position={'relative'}>
<Flex>
<Box fontWeight={'bold'} fontSize={'lg'} flex={1} mr={2}>
: {total}
</Box>
{isOwner && (
<>
<IconButton
icon={<RepeatIcon />}
aria-label={'refresh'}
variant={'outline'}
mr={4}
size={'sm'}
onClick={() => refetchData(pageNum)}
/>
<Button
variant={'outline'}
mr={2}
size={'sm'}
isLoading={isLoadingExport}
title={'换行数据导出时,会进行格式转换'}
onClick={() => onclickExport()}
>
</Button>
<Menu autoSelect={false}>
<MenuButton as={Button} size={'sm'}>
</MenuButton>
<MenuList>
<MenuItem
onClick={() =>
setEditInputData({
a: '',
q: ''
})
}
>
</MenuItem>
<MenuItem onClick={onOpenSelectFileModal}>/</MenuItem>
<MenuItem onClick={onOpenSelectCsvModal}>csv </MenuItem>
</MenuList>
</Menu>
</>
)}
</Flex>
<Flex mt={4}>
{isOwner && (splitDataQueue > 0 || embeddingQueue > 0) && (
<Box fontSize={'xs'}>
{splitDataQueue > 0 ? `${splitDataQueue}条数据正在拆分,` : ''}
{embeddingQueue > 0 ? `${embeddingQueue}条数据正在生成索引,` : ''}
...
</Box>
)}
<Box flex={1} />
<Input
maxW={'240px'}
size={'sm'}
value={searchText}
placeholder="搜索相关问题和答案,回车确认"
onChange={(e) => setSearchText(e.target.value)}
onBlur={() => {
if (searchText === lastSearch.current) return;
getData(1);
lastSearch.current = searchText;
}}
onKeyDown={(e) => {
if (searchText === lastSearch.current) return;
if (e.key === 'Enter') {
getData(1);
lastSearch.current = searchText;
}
}}
/>
</Flex>
<Box mt={4}>
<TableContainer minH={'500px'}>
<Table variant={'simple'} w={'100%'}>
<Thead>
<Tr>
<Th>
<Tooltip
label={
'对话时,会将用户的问题和知识库的 "匹配知识点" 进行比较,找到最相似的前 n 条记录,将这些记录的 "匹配知识点"+"补充知识点" 作为 chatgpt 的系统提示词。'
}
>
<QuestionOutlineIcon ml={1} />
</Tooltip>
</Th>
<Th></Th>
<Th></Th>
{isOwner && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{modelDataList.map((item) => (
<Tr key={item.id}>
<Td>
<Box {...tdStyles.current}>{item.q}</Box>
</Td>
<Td>
<Box {...tdStyles.current}>{item.a || '-'}</Box>
</Td>
<Td>{ModelDataStatusMap[item.status]}</Td>
{isOwner && (
<Td>
<IconButton
mr={5}
icon={<EditIcon />}
variant={'outline'}
aria-label={'delete'}
size={'sm'}
onClick={() =>
setEditInputData({
dataId: item.id,
q: item.q,
a: item.a
})
}
/>
<IconButton
icon={<DeleteIcon />}
variant={'outline'}
colorScheme={'gray'}
aria-label={'delete'}
size={'sm'}
onClick={async () => {
await delOneModelData(item.id);
refetchData(pageNum);
}}
/>
</Td>
)}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
<Flex mt={2} justifyContent={'flex-end'}>
<Pagination />
</Flex>
</Box>
<Loading loading={isLoading} fixed={false} />
{editInputData !== undefined && (
<InputModal
modelId={modelId}
defaultValues={editInputData}
onClose={() => setEditInputData(undefined)}
onSuccess={refetchData}
/>
)}
{isOpenSelectFileModal && (
<SelectFileModal
modelId={modelId}
onClose={onCloseSelectFileModal}
onSuccess={refetchData}
/>
)}
{isOpenSelectCsvModal && (
<SelectCsvModal modelId={modelId} onClose={onCloseSelectCsvModal} onSuccess={refetchData} />
)}
</Box>
);
};
export default ModelDataCard;

View File

@@ -27,16 +27,13 @@ import {
Table,
Thead,
Tbody,
Tfoot,
Tr,
Th,
Td,
TableContainer,
IconButton
Checkbox
} from '@chakra-ui/react';
import { DeleteIcon } from '@chakra-ui/icons';
import { QuestionOutlineIcon } from '@chakra-ui/icons';
import type { ModelSchema } from '@/types/mongoSchema';
import { useForm, UseFormReturn } from 'react-hook-form';
import { ChatModelMap, ModelVectorSearchModeMap, getChatModelList } from '@/constants/model';
import { formatPrice } from '@/utils/user';
@@ -49,23 +46,29 @@ import { getShareChatList, createShareChat, delShareChatById } from '@/api/chat'
import { useRouter } from 'next/router';
import { defaultShareChat } from '@/constants/model';
import type { ShareChatEditType } from '@/types/model';
import { formatTimeToChatTime, useCopyData } from '@/utils/tools';
import type { ModelSchema } from '@/types/mongoSchema';
import { formatTimeToChatTime, useCopyData, getErrText } from '@/utils/tools';
import MyIcon from '@/components/Icon';
import { useGlobalStore } from '@/store/global';
import { useUserStore } from '@/store/user';
const ModelEditForm = ({
formHooks,
isOwner,
canRead,
handleDelModel
}: {
formHooks: UseFormReturn<ModelSchema>;
isOwner: boolean;
canRead: boolean;
handleDelModel: () => void;
}) => {
const { toast } = useToast();
const { modelId } = useRouter().query as { modelId: string };
const { setLoading } = useGlobalStore();
const router = useRouter();
const { modelId } = router.query as { modelId: string };
const [refresh, setRefresh] = useState(false);
const { toast } = useToast();
const { setLoading } = useGlobalStore();
const { loadKbList } = useUserStore();
const { openConfirm, ConfirmChild } = useConfirm({
content: '确认删除该AI助手?'
@@ -86,6 +89,11 @@ const ModelEditForm = ({
onOpen: onOpenCreateShareChat,
onClose: onCloseCreateShareChat
} = useDisclosure();
const {
isOpen: isOpenKbSelect,
onOpen: onOpenKbSelect,
onClose: onCloseKbSelect
} = useDisclosure();
const { File, onOpen: onOpenSelectFile } = useSelectFile({
fileType: '.jpg,.png',
multiple: false
@@ -105,7 +113,7 @@ const ModelEditForm = ({
setRefresh((state) => !state);
} catch (err: any) {
toast({
title: typeof err === 'string' ? err : '头像选择异常',
title: getErrText(err, '头像选择异常'),
status: 'warning'
});
}
@@ -137,8 +145,12 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
copyData(url, '已复制分享地址');
resetShareChat(defaultShareChat);
} catch (error) {
console.log(error);
} catch (err) {
toast({
title: getErrText(err, '创建分享链接异常'),
status: 'warning'
});
console.log(err);
}
setLoading(false);
},
@@ -149,15 +161,52 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
onCloseCreateShareChat,
refetchShareChatList,
resetShareChat,
setLoading
setLoading,
toast
]
);
// format share used token
const formatTokens = (tokens: number) => {
if (tokens < 10000) return tokens;
return `${(tokens / 10000).toFixed(2)}`;
};
// init kb select list
const { data: kbList = [] } = useQuery(['loadKbList'], () => loadKbList());
const RenderSelectedKbList = useCallback(() => {
const kbs = getValues('chat.relatedKbs').map((id) => kbList.find((kb) => kb._id === id));
return (
<>
{kbs.map((item) =>
item ? (
<Card
key={item._id}
p={3}
mt={3}
cursor={'pointer'}
onClick={() => router.push(`/kb?kbId=${item._id}`)}
>
<Flex alignItems={'center'}>
<Image
src={item.avatar}
fallbackSrc="/icon/logo.png"
w={'20px'}
h={'20px'}
alt=""
></Image>
<Box ml={3} fontWeight={'bold'}>
{item.name}
</Box>
</Flex>
</Card>
) : null
)}
</>
);
}, [getValues, kbList, router]);
return (
<>
{/* basic info */}
@@ -165,13 +214,13 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
<Box fontWeight={'bold'}></Box>
<Flex alignItems={'center'} mt={4}>
<Box flex={'0 0 80px'} w={0}>
modelId:
modelId
</Box>
<Box>{getValues('_id')}</Box>
<Box userSelect={'all'}>{getValues('_id')}</Box>
</Flex>
<Flex mt={4} alignItems={'center'}>
<Box flex={'0 0 80px'} w={0}>
:
</Box>
<Image
src={getValues('avatar') || '/icon/logo.png'}
@@ -187,7 +236,7 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
<FormControl mt={4}>
<Flex alignItems={'center'}>
<Box flex={'0 0 80px'} w={0}>
:
</Box>
<Input
isDisabled={!isOwner}
@@ -200,7 +249,7 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
<Flex alignItems={'center'} mt={5}>
<Box flex={'0 0 80px'} w={0}>
:
</Box>
<Select
isDisabled={!isOwner}
@@ -219,7 +268,7 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
</Flex>
<Flex alignItems={'center'} mt={5}>
<Box flex={'0 0 80px'} w={0}>
:
</Box>
<Box>
{formatPrice(ChatModelMap[getValues('chat.chatModel')]?.price, 1000)}
@@ -234,7 +283,7 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
</Flex>
{isOwner && (
<Flex mt={5} alignItems={'center'}>
<Box flex={'0 0 120px'}>AI和知识库</Box>
<Box flex={'0 0 100px'}>AI助手</Box>
<Button
colorScheme={'gray'}
variant={'outline'}
@@ -247,91 +296,82 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
)}
</Card>
{/* model effect */}
<Card p={4}>
<Box fontWeight={'bold'}></Box>
<FormControl mt={4}>
<Flex alignItems={'center'}>
<Box flex={'0 0 80px'} w={0}>
<Box as={'span'} mr={2}>
{canRead && (
<Card p={4}>
<Box fontWeight={'bold'}></Box>
<FormControl mt={4}>
<Flex alignItems={'center'}>
<Box flex={'0 0 80px'} w={0}>
<Box as={'span'} mr={2}>
</Box>
<Tooltip label={'温度越高,模型的发散能力越强;温度越低,内容越严谨。'}>
<QuestionOutlineIcon />
</Tooltip>
</Box>
<Tooltip label={'温度越高,模型的发散能力越强;温度越低,内容越严谨。'}>
<QuestionOutlineIcon />
</Tooltip>
</Box>
<Slider
aria-label="slider-ex-1"
min={0}
max={10}
step={1}
value={getValues('chat.temperature')}
isDisabled={!isOwner}
onChange={(e) => {
setValue('chat.temperature', e);
setRefresh(!refresh);
}}
>
<SliderMark
<Slider
aria-label="slider-ex-1"
min={0}
max={10}
step={1}
value={getValues('chat.temperature')}
textAlign="center"
bg="myBlue.600"
color="white"
w={'18px'}
h={'18px'}
borderRadius={'100px'}
fontSize={'xs'}
transform={'translate(-50%, -200%)'}
isDisabled={!isOwner}
onChange={(e) => {
setValue('chat.temperature', e);
setRefresh(!refresh);
}}
>
{getValues('chat.temperature')}
</SliderMark>
<SliderTrack>
<SliderFilledTrack bg={'myBlue.700'} />
</SliderTrack>
<SliderThumb />
</Slider>
</Flex>
</FormControl>
<Flex mt={4} alignItems={'center'}>
<Box mr={4}></Box>
<Switch
isDisabled={!isOwner}
isChecked={getValues('chat.useKb')}
onChange={() => {
setValue('chat.useKb', !getValues('chat.useKb'));
setRefresh(!refresh);
}}
/>
</Flex>
{getValues('chat.useKb') && (
<Flex mt={4} alignItems={'center'}>
<Box mr={4} whiteSpace={'nowrap'}>
&emsp;
</Box>
<Select
isDisabled={!isOwner}
{...register('chat.searchMode', { required: '搜索模式不能为空' })}
>
{Object.entries(ModelVectorSearchModeMap).map(([key, { text }]) => (
<option key={key} value={key}>
{text}
</option>
))}
</Select>
</Flex>
)}
<SliderMark
value={getValues('chat.temperature')}
textAlign="center"
bg="myBlue.600"
color="white"
w={'18px'}
h={'18px'}
borderRadius={'100px'}
fontSize={'xs'}
transform={'translate(-50%, -200%)'}
>
{getValues('chat.temperature')}
</SliderMark>
<SliderTrack>
<SliderFilledTrack bg={'myBlue.700'} />
</SliderTrack>
<SliderThumb />
</Slider>
</Flex>
</FormControl>
{getValues('chat.relatedKbs').length > 0 && (
<Flex mt={4} alignItems={'center'}>
<Box mr={4} whiteSpace={'nowrap'}>
&emsp;
</Box>
<Select
isDisabled={!isOwner}
{...register('chat.searchMode', { required: '搜索模式不能为空' })}
>
{Object.entries(ModelVectorSearchModeMap).map(([key, { text }]) => (
<option key={key} value={key}>
{text}
</option>
))}
</Select>
</Flex>
)}
<Box mt={4}>
<Box mb={1}></Box>
<Textarea
rows={8}
maxLength={-1}
isDisabled={!isOwner}
placeholder={'模型默认的 prompt 词,通过调整该内容,可以引导模型聊天方向。'}
{...register('chat.systemPrompt')}
/>
</Box>
</Card>
<Box mt={4}>
<Box mb={1}></Box>
<Textarea
rows={8}
maxLength={-1}
isDisabled={!isOwner}
placeholder={'模型默认的 prompt 词,通过调整该内容,可以引导模型聊天方向。'}
{...register('chat.systemPrompt')}
/>
</Box>
</Card>
)}
{isOwner && (
<>
{/* model share setting */}
@@ -339,7 +379,9 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
<Box fontWeight={'bold'}></Box>
<Box>
<Flex mt={5} alignItems={'center'}>
<Box mr={1}>:</Box>
<Box mr={1} fontSize={['sm', 'md']}>
:
</Box>
<Tooltip label="开启模型分享后,你的模型将会出现在共享市场,可供 FastGpt 所有用户使用。用户使用时不会消耗你的 tokens而是消耗使用者的 tokens。">
<QuestionOutlineIcon mr={3} />
</Tooltip>
@@ -350,7 +392,8 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
setRefresh(!refresh);
}}
/>
<Box ml={12} mr={1}>
<Box ml={12} mr={1} fontSize={['sm', 'md']}>
:
</Box>
<Tooltip label="开启分享详情后,其他用户可以查看该模型的特有数据:温度、提示词和数据集。">
@@ -376,90 +419,104 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
</Box>
</Box>
</Card>
{/* shareChat */}
<Card p={4}>
<Flex justifyContent={'space-between'}>
<Box fontWeight={'bold'}>
<Tooltip label="可以直接分享该模型给其他用户去进行对话对方无需登录即可直接进行对话。注意这个功能会消耗你账号的tokens。请保管好链接和密码。">
<QuestionOutlineIcon ml={1} />
</Tooltip>
Beta
</Box>
<Box fontWeight={'bold'}></Box>
<Button
size={'sm'}
variant={'outline'}
colorScheme={'myBlue'}
{...(shareChatList.length >= 10
? {
isDisabled: true,
title: '最多创建10组'
}
: {})}
onClick={onOpenCreateShareChat}
onClick={onOpenKbSelect}
>
</Button>
</Flex>
<TableContainer mt={1} minH={'100px'}>
<Table variant={'simple'} w={'100%'}>
<Thead>
<Tr>
<Th></Th>
<Th></Th>
<Th></Th>
<Th>tokens消耗</Th>
<Th>使</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{shareChatList.map((item) => (
<Tr key={item._id}>
<Td>{item.name}</Td>
<Td>{item.password === '1' ? '已开启' : '未使用'}</Td>
<Td>{item.maxContext}</Td>
<Td>{formatTokens(item.tokens)}</Td>
<Td>{item.lastTime ? formatTimeToChatTime(item.lastTime) : '未使用'}</Td>
<Td>
<Flex>
<MyIcon
mr={3}
name="copy"
w={'14px'}
cursor={'pointer'}
_hover={{ color: 'myBlue.600' }}
onClick={() => {
const url = `${location.origin}/chat/share?shareId=${item._id}`;
copyData(url, '已复制分享地址');
}}
/>
<MyIcon
name="delete"
w={'14px'}
cursor={'pointer'}
_hover={{ color: 'red' }}
onClick={async () => {
setLoading(true);
try {
await delShareChatById(item._id);
refetchShareChatList();
} catch (error) {
console.log(error);
}
setLoading(false);
}}
/>
</Flex>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
<RenderSelectedKbList />
</Card>
</>
)}
{/* shareChat */}
<Card p={4} gridColumnStart={1} gridColumnEnd={[2, 3]}>
<Flex justifyContent={'space-between'}>
<Box fontWeight={'bold'}>
<Tooltip label="可以直接分享该模型给其他用户去进行对话对方无需登录即可直接进行对话。注意这个功能会消耗你账号的tokens。请保管好链接和密码。">
<QuestionOutlineIcon ml={1} />
</Tooltip>
Beta
</Box>
<Button
size={'sm'}
variant={'outline'}
colorScheme={'myBlue'}
{...(shareChatList.length >= 10
? {
isDisabled: true,
title: '最多创建10组'
}
: {})}
onClick={onOpenCreateShareChat}
>
</Button>
</Flex>
<TableContainer mt={1} minH={'100px'}>
<Table variant={'simple'} w={'100%'}>
<Thead>
<Tr>
<Th></Th>
<Th></Th>
<Th></Th>
<Th>tokens消耗</Th>
<Th>使</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{shareChatList.map((item) => (
<Tr key={item._id}>
<Td>{item.name}</Td>
<Td>{item.password === '1' ? '已开启' : '未使用'}</Td>
<Td>{item.maxContext}</Td>
<Td>{formatTokens(item.tokens)}</Td>
<Td>{item.lastTime ? formatTimeToChatTime(item.lastTime) : '未使用'}</Td>
<Td>
<Flex>
<MyIcon
mr={3}
name="copy"
w={'14px'}
cursor={'pointer'}
_hover={{ color: 'myBlue.600' }}
onClick={() => {
const url = `${location.origin}/chat/share?shareId=${item._id}`;
copyData(url, '已复制分享地址');
}}
/>
<MyIcon
name="delete"
w={'14px'}
cursor={'pointer'}
_hover={{ color: 'red' }}
onClick={async () => {
setLoading(true);
try {
await delShareChatById(item._id);
refetchShareChatList();
} catch (error) {
console.log(error);
}
setLoading(false);
}}
/>
</Flex>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Card>
{/* create shareChat modal */}
<Modal isOpen={isOpenCreateShareChat} onClose={onCloseCreateShareChat}>
<ModalOverlay />
@@ -539,6 +596,52 @@ ${e.password ? `密码为: ${e.password}` : ''}`;
</ModalFooter>
</ModalContent>
</Modal>
{/* select kb modal */}
<Modal isOpen={isOpenKbSelect} onClose={onCloseKbSelect}>
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
<ModalCloseButton />
<ModalBody>
{kbList.map((item) => (
<Card key={item._id} p={3} mb={3}>
<Checkbox
isChecked={getValues('chat.relatedKbs').includes(item._id)}
onChange={(e) => {
const ids = getValues('chat.relatedKbs');
// toggle to true
if (e.target.checked) {
setValue('chat.relatedKbs', ids.concat(item._id));
} else {
const i = ids.findIndex((id) => id === item._id);
ids.splice(i, 1);
setValue('chat.relatedKbs', ids);
}
setRefresh(!refresh);
}}
>
<Flex alignItems={'center'}>
<Image
src={item.avatar}
fallbackSrc="/icon/logo.png"
w={'20px'}
h={'20px'}
alt=""
></Image>
<Box ml={3} fontWeight={'bold'}>
{item.name}
</Box>
</Flex>
</Checkbox>
</Card>
))}
</ModalBody>
<ModalFooter>
<Button onClick={onCloseKbSelect}>,</Button>
</ModalFooter>
</ModalContent>
</Modal>
<File onSelect={onSelectFile} />
<ConfirmChild />
</>

View File

@@ -1,162 +0,0 @@
import React, { useState } from 'react';
import {
Box,
Flex,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Input,
Textarea
} from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useConfirm } from '@/hooks/useConfirm';
import { useMutation } from '@tanstack/react-query';
import { postModelDataSplitData, getWebContent } from '@/api/model';
import { formatPrice } from '@/utils/user';
const SelectUrlModal = ({
onClose,
onSuccess,
modelId
}: {
onClose: () => void;
onSuccess: () => void;
modelId: string;
}) => {
const { toast } = useToast();
const [webUrl, setWebUrl] = useState('');
const [webText, setWebText] = useState('');
const [prompt, setPrompt] = useState(''); // 提示词
const { openConfirm, ConfirmChild } = useConfirm({
content: '确认导入该文件,需要一定时间进行拆解,该任务无法终止!如果余额不足,任务讲被终止。'
});
const { mutate: onclickImport, isLoading: isImporting } = useMutation({
mutationFn: async () => {
if (!webText) return;
await postModelDataSplitData({
modelId,
chunks: [],
prompt: `下面是"${prompt || '一段长文本'}"`,
mode: 'qa'
});
toast({
title: '导入数据成功,需要一段拆解和训练',
status: 'success'
});
onClose();
onSuccess();
},
onError(error) {
console.log(error);
toast({
title: '导入数据失败',
status: 'error'
});
}
});
const { mutate: onclickFetchingUrl, isLoading: isFetching } = useMutation({
mutationFn: async () => {
if (!webUrl) return;
const res = await getWebContent(webUrl);
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(res, 'text/html');
const data = htmlDoc?.body?.innerText || '';
if (!data) {
throw new Error('获取不到数据');
}
setWebText(data.replace(/\s+/g, ' '));
},
onError(error) {
console.log(error);
toast({
status: 'error',
title: '获取网站内容失败'
});
}
});
return (
<Modal isOpen={true} onClose={onClose} isCentered>
<ModalOverlay />
<ModalContent maxW={'min(900px, 90vw)'} m={0} position={'relative'} h={'90vh'}>
<ModalHeader></ModalHeader>
<ModalCloseButton />
<ModalBody
display={'flex'}
flexDirection={'column'}
p={4}
h={'100%'}
alignItems={'center'}
justifyContent={'center'}
fontSize={'sm'}
>
<Box mt={2} maxW={['100%', '70%']}>
Gpt会对文本进行
QA tokens
</Box>
<Flex w={'100%'} alignItems={'center'} my={4}>
<Box flex={'0 0 70px'}></Box>
<Input
mx={2}
placeholder="需要获取内容的地址。例如https://fastgpt.ahapocket.cn"
value={webUrl}
onChange={(e) => setWebUrl(e.target.value)}
size={'sm'}
/>
<Button isLoading={isFetching} onClick={() => onclickFetchingUrl()}>
</Button>
</Flex>
<Flex w={'100%'} alignItems={'center'} my={4}>
<Box flex={'0 0 70px'} mr={2}>
</Box>
<Input
placeholder="内容提示词。例如: Laf的介绍/关于gpt4的论文/一段长文本"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
size={'sm'}
/>
</Flex>
<Textarea
flex={'1 0 0'}
h={0}
w={'100%'}
placeholder="网站的内容"
maxLength={-1}
resize={'none'}
fontSize={'xs'}
whiteSpace={'pre-wrap'}
value={webText}
onChange={(e) => setWebText(e.target.value)}
/>
</ModalBody>
<Flex px={6} pt={2} pb={4}>
<Box flex={1}></Box>
<Button variant={'outline'} mr={3} onClick={onClose}>
</Button>
<Button
isLoading={isImporting}
isDisabled={webText === ''}
onClick={openConfirm(onclickImport)}
>
</Button>
</Flex>
</ModalContent>
<ConfirmChild />
</Modal>
);
};
export default SelectUrlModal;

View File

@@ -2,15 +2,13 @@ import React, { useCallback, useState, useMemo, useEffect } from 'react';
import { useRouter } from 'next/router';
import { delModelById, putModelById } from '@/api/model';
import type { ModelSchema } from '@/types/mongoSchema';
import { Card, Box, Flex, Button, Tag, Grid } from '@chakra-ui/react';
import { Card, Box, Flex, Button, Grid } from '@chakra-ui/react';
import { useToast } from '@/hooks/useToast';
import { useForm } from 'react-hook-form';
import { formatModelStatus } from '@/constants/model';
import { useQuery } from '@tanstack/react-query';
import { useUserStore } from '@/store/user';
import { useLoading } from '@/hooks/useLoading';
import ModelEditForm from './components/ModelEditForm';
import ModelDataCard from './components/ModelDataCard';
const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
const { toast } = useToast();
@@ -19,7 +17,7 @@ const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
const { Loading, setIsLoading } = useLoading();
const [btnLoading, setBtnLoading] = useState(false);
const formHooks = useForm<ModelSchema>({
const formHooks = useForm({
defaultValues: modelDetail
});
@@ -45,6 +43,11 @@ const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
[modelDetail.userId, userInfo?._id]
);
const canRead = useMemo(
() => isOwner || isLoading || modelDetail.share.isShareDetail,
[isLoading, isOwner, modelDetail.share.isShareDetail]
);
/* 点击删除 */
const handleDelModel = useCallback(async () => {
if (!modelDetail) return;
@@ -80,13 +83,9 @@ const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
name: data.name,
avatar: data.avatar || '/icon/logo.png',
chat: data.chat,
share: data.share,
security: data.security
});
toast({
title: '更新成功',
status: 'success'
share: data.share
});
refreshModel.updateModelDetail(data);
} catch (err: any) {
toast({
@@ -116,9 +115,12 @@ const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
});
}, [formHooks.formState.errors, toast]);
useEffect(() => {
router.prefetch('/chat');
const saveUpdateModel = useCallback(
() => formHooks.handleSubmit(saveSubmitSuccess, saveSubmitError)(),
[formHooks, saveSubmitError, saveSubmitSuccess]
);
useEffect(() => {
window.onbeforeunload = (e) => {
e.preventDefault();
e.returnValue = '内容已修改,确认离开页面吗?';
@@ -138,13 +140,6 @@ const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
<Box fontSize={'xl'} fontWeight={'bold'}>
{modelDetail.name}
</Box>
<Tag
ml={2}
variant="solid"
colorScheme={formatModelStatus[modelDetail.status].colorTheme}
>
{formatModelStatus[modelDetail.status].text}
</Tag>
<Box flex={1} />
<Button variant={'outline'} onClick={handlePreviewChat}>
@@ -153,7 +148,18 @@ const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
<Button
isLoading={btnLoading}
ml={4}
onClick={formHooks.handleSubmit(saveSubmitSuccess, saveSubmitError)}
onClick={async () => {
try {
await saveUpdateModel();
toast({
title: '更新成功',
status: 'success'
});
} catch (error) {
console.log(error);
error;
}
}}
>
</Button>
@@ -165,9 +171,6 @@ const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
<Box as={'h3'} fontSize={'xl'} fontWeight={'bold'} flex={1}>
{modelDetail.name}
</Box>
<Tag ml={2} colorScheme={formatModelStatus[modelDetail.status].colorTheme}>
{formatModelStatus[modelDetail.status].text}
</Tag>
</Flex>
<Box mt={4} textAlign={'right'}>
<Button variant={'outline'} size={'sm'} onClick={handlePreviewChat}>
@@ -178,7 +181,18 @@ const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
ml={4}
size={'sm'}
isLoading={btnLoading}
onClick={formHooks.handleSubmit(saveSubmitSuccess, saveSubmitError)}
onClick={async () => {
try {
await saveUpdateModel();
toast({
title: '更新成功',
status: 'success'
});
} catch (error) {
console.log(error);
error;
}
}}
>
</Button>
@@ -188,11 +202,12 @@ const ModelDetail = ({ modelId, isPc }: { modelId: string; isPc: boolean }) => {
)}
</Card>
<Grid mt={5} gridTemplateColumns={['1fr', '1fr 1fr']} gridGap={5}>
<ModelEditForm formHooks={formHooks} handleDelModel={handleDelModel} isOwner={isOwner} />
<Card p={4} gridColumnStart={[1, 1]} gridColumnEnd={[2, 3]}>
<ModelDataCard modelId={modelId} isOwner={isOwner} />
</Card>
<ModelEditForm
formHooks={formHooks}
handleDelModel={handleDelModel}
isOwner={isOwner}
canRead={canRead}
/>
</Grid>
<Loading loading={isLoading} fixed={false} />
</Box>

View File

@@ -1,22 +1,21 @@
import React, { useEffect } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import { useScreen } from '@/hooks/useScreen';
import { useRouter } from 'next/router';
import ModelList from './components/ModelList';
import dynamic from 'next/dynamic';
import { useUserStore } from '@/store/user';
import { useGlobalStore } from '@/store/global';
import Loading from '@/components/Loading';
import SideBar from '@/components/SideBar';
const ModelDetail = dynamic(() => import('./components/detail/index'), {
loading: () => <Loading fixed={false} />,
ssr: false
});
const Model = ({ modelId, isPcDevice }: { modelId: string; isPcDevice: boolean }) => {
const Model = ({ modelId }: { modelId: string }) => {
const router = useRouter();
const { isPc } = useScreen({
defaultIsPc: isPcDevice
});
const { isPc } = useGlobalStore();
const { lastModelId } = useUserStore();
// redirect modelId
@@ -27,12 +26,12 @@ const Model = ({ modelId, isPcDevice }: { modelId: string; isPcDevice: boolean }
}, [isPc, lastModelId, modelId, router]);
return (
<Flex h={'100%'} position={'relative'}>
<Flex h={'100%'} position={'relative'} overflow={'hidden'}>
{/* 模型列表 */}
{(isPc || !modelId) && (
<Box w={['100%', '250px']}>
<SideBar w={['100%', '0 0 250px', '0 0 270px', '0 0 290px']}>
<ModelList modelId={modelId} />
</Box>
</SideBar>
)}
<Box flex={1} h={'100%'} position={'relative'}>
{modelId && <ModelDetail modelId={modelId} isPc={isPc} />}
@@ -45,7 +44,6 @@ export default Model;
Model.getInitialProps = ({ query, req }: any) => {
return {
modelId: query?.modelId || '',
isPcDevice: !/Mobile/.test(req ? req.headers['user-agent'] : navigator.userAgent)
modelId: query?.modelId || ''
};
};

View File

@@ -18,6 +18,7 @@ const ShareModelList = ({
<>
{models.map((model) => (
<Flex
w={'100%'}
flexDirection={'column'}
key={model._id}
p={4}
@@ -37,7 +38,14 @@ const ShareModelList = ({
{model.name}
</Box>
</Flex>
<Box flex={1} className={styles.intro} my={4} fontSize={'sm'} color={'blackAlpha.600'}>
<Box
flex={1}
className={styles.intro}
my={4}
fontSize={'sm'}
wordBreak={'break-all'}
color={'blackAlpha.600'}
>
{model.share.intro || '这个AI助手还没有介绍~'}
</Box>
<Flex justifyContent={'space-between'}>

View File

@@ -5,6 +5,11 @@ import MyIcon from '@/components/Icon';
import { useRouter } from 'next/router';
const list = [
{
icon: 'kb',
label: '我的知识库',
link: '/kb'
},
{
icon: 'shareMarket',
label: 'AI助手市场',
@@ -19,6 +24,11 @@ const list = [
icon: 'develop',
label: '开发',
link: '/openapi'
},
{
icon: 'git',
label: 'Git项目地址',
link: 'https://github.com/c121914yu/FastGPT'
}
];

View File

@@ -37,7 +37,8 @@ export const proxyError: Record<string, boolean> = {
export enum ERROR_ENUM {
unAuthorization = 'unAuthorization',
insufficientQuota = 'insufficientQuota',
unAuthModel = 'unAuthModel'
unAuthModel = 'unAuthModel',
unAuthKb = 'unAuthKb'
}
export const ERROR_RESPONSE: Record<
any,
@@ -65,5 +66,11 @@ export const ERROR_RESPONSE: Record<
statusText: ERROR_ENUM.unAuthModel,
message: '无权使用该模型',
data: null
},
[ERROR_ENUM.unAuthKb]: {
code: 512,
statusText: ERROR_ENUM.unAuthKb,
message: '无权使用该知识库',
data: null
}
};

View File

@@ -5,10 +5,10 @@ import { pushSplitDataBill } from '@/service/events/pushBill';
import { generateVector } from './generateVector';
import { openaiError2 } from '../errorCode';
import { PgClient } from '@/service/pg';
import { ModelSplitDataSchema } from '@/types/mongoSchema';
import { SplitDataSchema } from '@/types/mongoSchema';
import { modelServiceToolMap } from '../utils/chat';
import { ChatRoleEnum } from '@/constants/chat';
import { getErrMessage } from '../utils/tools';
import { getErrText } from '@/utils/tools';
export async function generateQA(next = false): Promise<any> {
if (process.env.queueTask !== '1') {
@@ -32,7 +32,7 @@ export async function generateQA(next = false): Promise<any> {
{ $sample: { size: 1 } }
]);
const dataItem: ModelSplitDataSchema = data[0];
const dataItem: SplitDataSchema = data[0];
if (!dataItem) {
console.log('没有需要生成 QA 的数据');
@@ -56,7 +56,7 @@ export async function generateQA(next = false): Promise<any> {
// 余额不够了, 清空该记录
await SplitData.findByIdAndUpdate(dataItem._id, {
textList: [],
errorText: getErrMessage(err, '获取 OpenAi Key 失败')
errorText: getErrText(err, '获取 OpenAi Key 失败')
});
generateQA(true);
return;
@@ -127,14 +127,15 @@ A2:
const resultList = successResponse.map((item) => item.result).flat();
await Promise.allSettled([
// 删掉后5个数据
SplitData.findByIdAndUpdate(dataItem._id, {
textList: dataItem.textList.slice(0, -5)
}), // 删掉后5个数据
}),
// 生成的内容插入 pg
PgClient.insert('modelData', {
values: resultList.map((item) => [
{ key: 'user_id', value: dataItem.userId },
{ key: 'model_id', value: dataItem.modelId },
{ key: 'kb_id', value: dataItem.kbId },
{ key: 'q', value: item.q },
{ key: 'a', value: item.a },
{ key: 'status', value: 'waiting' }

View File

@@ -2,7 +2,7 @@ import { openaiCreateEmbedding } from '../utils/chat/openai';
import { getApiKey } from '../utils/auth';
import { openaiError2 } from '../errorCode';
import { PgClient } from '@/service/pg';
import { getErrMessage } from '../utils/tools';
import { getErrText } from '@/utils/tools';
export async function generateVector(next = false): Promise<any> {
if (process.env.queueTask !== '1') {
@@ -51,7 +51,7 @@ export async function generateVector(next = false): Promise<any> {
where: [['id', dataId]]
});
generateVector(true);
getErrMessage(err, '获取 OpenAi Key 失败');
getErrText(err, '获取 OpenAi Key 失败');
return;
}
@@ -86,9 +86,13 @@ export async function generateVector(next = false): Promise<any> {
// 没有余额或者凭证错误时,拒绝任务
if (dataId && openaiError2[error?.response?.data?.error?.type]) {
console.log('删除向量生成任务记录');
await PgClient.delete('modelData', {
where: [['id', dataId]]
});
try {
await PgClient.delete('modelData', {
where: [['id', dataId]]
});
} catch (error) {
error;
}
generateVector(true);
return;
}

View File

@@ -26,7 +26,7 @@ export const pushChatBill = async ({
await connectToDatabase();
// 计算价格
const unitPrice = ChatModelMap[chatModel]?.price || 5;
const unitPrice = ChatModelMap[chatModel]?.price || 3;
const price = unitPrice * tokens;
try {

28
src/service/models/kb.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Schema, model, models, Model } from 'mongoose';
import { kbSchema as SchemaType } from '@/types/mongoSchema';
const kbSchema = new Schema({
userId: {
type: Schema.Types.ObjectId,
ref: 'user',
required: true
},
updateTime: {
type: Date,
default: () => new Date()
},
avatar: {
type: String,
default: '/icon/logo.png'
},
name: {
type: String,
required: true
},
tags: {
type: [String],
default: []
}
});
export const KB: Model<SchemaType> = models['kb'] || model('kb', kbSchema);

View File

@@ -31,10 +31,10 @@ const ModelSchema = new Schema({
default: () => new Date()
},
chat: {
useKb: {
// use knowledge base to search
type: Boolean,
default: false
relatedKbs: {
type: [Schema.Types.ObjectId],
ref: 'kb',
default: []
},
searchMode: {
// knowledge base search mode
@@ -79,33 +79,6 @@ const ModelSchema = new Schema({
type: Number,
default: 0
}
},
security: {
type: {
domain: {
type: [String],
default: ['*']
},
contextMaxLen: {
type: Number,
default: 20
},
contentMaxLen: {
type: Number,
default: 4000
},
expiredTime: {
type: Number,
default: 1,
set: (val: number) => val * (60 * 60 * 1000)
},
maxLoadAmount: {
// 负数代表不限制
type: Number,
default: -1
}
},
default: {}
}
});

View File

@@ -1,6 +1,6 @@
/* 模型的知识库 */
import { Schema, model, models, Model as MongoModel } from 'mongoose';
import { ModelSplitDataSchema as SplitDataType } from '@/types/mongoSchema';
import { SplitDataSchema as SplitDataType } from '@/types/mongoSchema';
const SplitDataSchema = new Schema({
userId: {
@@ -13,9 +13,9 @@ const SplitDataSchema = new Schema({
type: String,
required: true
},
modelId: {
kbId: {
type: Schema.Types.ObjectId,
ref: 'model',
ref: 'kb',
required: true
},
textList: {

View File

@@ -28,7 +28,7 @@ export async function connectToDatabase(): Promise<void> {
}
generateQA();
generateVector(true);
generateVector();
// 创建代理对象
if (process.env.AXIOS_PROXY_HOST && process.env.AXIOS_PROXY_PORT) {
@@ -52,3 +52,4 @@ export * from './models/openapi';
export * from './models/promotionRecord';
export * from './models/collection';
export * from './models/shareChat';
export * from './models/kb';

View File

@@ -48,7 +48,7 @@ export const searchKb = async ({
where: [
['status', ModelDataStatusEnum.ready],
'AND',
['model_id', model._id],
`kb_id IN (${model.chat.relatedKbs.map((item) => `'${item}'`).join(',')})`,
'AND',
`vector <=> '[${promptVector}]' < ${similarity}`
],

View File

@@ -37,7 +37,7 @@ export const jsonRes = <T = any>(
if (typeof error === 'string') {
msg = error;
} else if (proxyError[error?.code]) {
msg = '服务器代理出错';
msg = '接口连接异常';
} else if (error?.response?.data?.error?.message) {
msg = error?.response?.data?.error?.message;
} else if (openaiError2[error?.response?.data?.error?.type]) {

View File

@@ -1,7 +1,7 @@
import type { NextApiRequest } from 'next';
import jwt from 'jsonwebtoken';
import cookie from 'cookie';
import { Chat, Model, OpenApi, User, ShareChat } from '../mongo';
import { Chat, Model, OpenApi, User, ShareChat, KB } from '../mongo';
import type { ModelSchema } from '@/types/mongoSchema';
import type { ChatItemSimpleType } from '@/types/chat';
import mongoose from 'mongoose';
@@ -34,6 +34,13 @@ export const authToken = (req: NextApiRequest): Promise<string> => {
});
};
export const getOpenAiKey = () => {
// 纯字符串类型
const keys = process.env.OPENAIKEY?.split(',') || [];
const i = Math.floor(Math.random() * keys.length);
return keys[i] || (process.env.OPENAIKEY as string);
};
/* 获取 api 请求的 key */
export const getApiKey = async ({
model,
@@ -52,7 +59,7 @@ export const getApiKey = async ({
const keyMap = {
[OpenAiChatEnum.GPT35]: {
userOpenAiKey: user.openaiKey || '',
systemAuthKey: process.env.OPENAIKEY as string
systemAuthKey: getOpenAiKey() as string
},
[OpenAiChatEnum.GPT4]: {
userOpenAiKey: user.openaiKey || '',
@@ -129,6 +136,18 @@ export const authModel = async ({
return { model, showModelDetail: model.share.isShareDetail || userId === String(model.userId) };
};
// 知识库操作权限
export const authKb = async ({ kbId, userId }: { kbId: string; userId: string }) => {
const kb = await KB.findOne({
_id: kbId,
userId
});
if (kb) {
return kb;
}
return Promise.reject(ERROR_ENUM.unAuthKb);
};
// 获取对话校验
export const authChat = async ({
modelId,
@@ -211,7 +230,9 @@ export const authShareChat = async ({
// 获取 model 数据
const { model, showModelDetail } = await authModel({
modelId,
userId
userId,
authOwner: false,
reserveDetail: true
});
// 获取 user 的 apiKey

View File

@@ -1,19 +1,11 @@
import { modelToolMap } from '@/utils/chat';
import { ChatCompletionType, StreamResponseType } from './index';
import { ChatRoleEnum } from '@/constants/chat';
import axios from 'axios';
import mongoose from 'mongoose';
import { NEW_CHATID_HEADER } from '@/constants/chat';
import { ClaudeEnum } from '@/constants/model';
/* 模型对话 */
export const lafClaudChat = async ({
apiKey,
messages,
stream,
chatId,
res
}: ChatCompletionType) => {
export const claudChat = async ({ apiKey, messages, stream, chatId, res }: ChatCompletionType) => {
const conversationId = chatId || String(new mongoose.Types.ObjectId());
// create a new chat
!chatId &&
@@ -25,11 +17,11 @@ export const lafClaudChat = async ({
.filter((item) => item.obj === 'System')
.map((item) => item.value)
.join('\n');
const systemPromptText = systemPrompt ? `你本次知识:${systemPrompt}\n` : '';
const systemPromptText = systemPrompt ? `你本次知识:${systemPrompt}\n下面是我的问题:` : '';
const prompt = `${systemPromptText}我的问题是:'${messages[messages.length - 1].value}'`;
const prompt = `${systemPromptText}'${messages[messages.length - 1].value}'`;
const lafResponse = await axios.post(
const response = await axios.post(
process.env.CLAUDE_BASE_URL || '',
{
prompt,
@@ -45,10 +37,10 @@ export const lafClaudChat = async ({
}
);
const responseText = stream ? '' : lafResponse.data?.text || '';
const responseText = stream ? '' : response.data?.text || '';
return {
streamResponse: lafResponse,
streamResponse: response,
responseMessages: messages.concat({ obj: ChatRoleEnum.AI, value: responseText }),
responseText,
totalTokens: 0
@@ -56,24 +48,20 @@ export const lafClaudChat = async ({
};
/* openai stream response */
export const lafClaudStreamResponse = async ({
stream,
chatResponse,
prompts
}: StreamResponseType) => {
export const claudStreamResponse = async ({ res, chatResponse, prompts }: StreamResponseType) => {
try {
let responseContent = '';
try {
const decoder = new TextDecoder();
for await (const chunk of chatResponse.data as any) {
if (stream.destroyed) {
if (!res.writable) {
// 流被中断了,直接忽略后面的内容
break;
}
const content = decoder.decode(chunk);
responseContent += content;
content && stream.push(content.replace(/\n/g, '<br/>'));
content && res.write(content);
}
} catch (error) {
console.log('pipe error', error);

View File

@@ -4,15 +4,13 @@ import type { ChatModelType } from '@/constants/model';
import { ChatRoleEnum, SYSTEM_PROMPT_HEADER } from '@/constants/chat';
import { OpenAiChatEnum, ClaudeEnum } from '@/constants/model';
import { chatResponse, openAiStreamResponse } from './openai';
import { lafClaudChat, lafClaudStreamResponse } from './claude';
import { claudChat, claudStreamResponse } from './claude';
import type { NextApiResponse } from 'next';
import type { PassThrough } from 'stream';
export type ChatCompletionType = {
apiKey: string;
temperature: number;
messages: ChatItemSimpleType[];
stream: boolean;
[key: string]: any;
};
export type ChatCompletionResponseType = {
@@ -22,7 +20,6 @@ export type ChatCompletionResponseType = {
totalTokens: number;
};
export type StreamResponseType = {
stream: PassThrough;
chatResponse: any;
prompts: ChatItemSimpleType[];
res: NextApiResponse;
@@ -70,8 +67,8 @@ export const modelServiceToolMap: Record<
})
},
[ClaudeEnum.Claude]: {
chatCompletion: lafClaudChat,
streamResponse: lafClaudStreamResponse
chatCompletion: claudChat,
streamResponse: claudStreamResponse
}
};
@@ -131,7 +128,6 @@ export const ChatContextFilter = ({
export const resStreamResponse = async ({
model,
res,
stream,
chatResponse,
systemPrompt,
prompts
@@ -144,21 +140,17 @@ export const resStreamResponse = async ({
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Cache-Control', 'no-cache, no-transform');
systemPrompt && res.setHeader(SYSTEM_PROMPT_HEADER, encodeURIComponent(systemPrompt));
stream.pipe(res);
const { responseContent, totalTokens, finishMessages } = await modelServiceToolMap[
model
].streamResponse({
chatResponse,
stream,
prompts,
res,
systemPrompt
});
// close stream
!stream.destroyed && stream.push(null);
stream.destroy();
res.end();
return { responseContent, totalTokens, finishMessages };
};

View File

@@ -7,6 +7,7 @@ import { adaptChatItem_openAI } from '@/utils/chat/openai';
import { modelToolMap } from '@/utils/chat';
import { ChatCompletionType, ChatContextFilter, StreamResponseType } from './index';
import { ChatRoleEnum } from '@/constants/chat';
import { getOpenAiKey } from '../auth';
export const getOpenAIApi = (apiKey: string) => {
const configuration = new Configuration({
@@ -27,7 +28,7 @@ export const openaiCreateEmbedding = async ({
userId: string;
textArr: string[];
}) => {
const systemAuthKey = process.env.OPENAIKEY as string;
const systemAuthKey = getOpenAiKey();
// 获取 chatAPI
const chatAPI = getOpenAIApi(userOpenAiKey || systemAuthKey);
@@ -109,8 +110,8 @@ export const chatResponse = async ({
/* openai stream response */
export const openAiStreamResponse = async ({
res,
model,
stream,
chatResponse,
prompts
}: StreamResponseType & {
@@ -128,7 +129,7 @@ export const openAiStreamResponse = async ({
const content: string = json?.choices?.[0].delta.content || '';
responseContent += content;
!stream.destroyed && content && stream.push(content.replace(/\n/g, '<br/>'));
res.writable && content && res.write(content);
} catch (error) {
error;
}
@@ -138,7 +139,7 @@ export const openAiStreamResponse = async ({
const decoder = new TextDecoder();
const parser = createParser(onParse);
for await (const chunk of chatResponse.data as any) {
if (stream.destroyed) {
if (!res.writable) {
// 流被中断了,直接忽略后面的内容
break;
}

View File

@@ -1,4 +1,5 @@
import type { NextApiResponse } from 'next';
import type { NextApiResponse, NextApiHandler, NextApiRequest } from 'next';
import NextCors from 'nextjs-cors';
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
@@ -37,11 +38,19 @@ export const axiosConfig = () => ({
}
});
/**
* get error message
*/
export const getErrMessage = (err: any, defaultMsg = ''): string => {
const msg = typeof err === 'string' ? err : err?.message || defaultMsg || '';
msg && console.log('error =>', msg);
return msg;
};
export function withNextCors(handler: NextApiHandler): NextApiHandler {
return async function nextApiHandlerWrappedWithNextCors(
req: NextApiRequest,
res: NextApiResponse
) {
const methods = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'];
const origin = req.headers.origin;
await NextCors(req, res, {
methods,
origin: origin,
optionsSuccessStatus: 200
});
return handler(req, res);
};
}

View File

@@ -5,6 +5,9 @@ import { immer } from 'zustand/middleware/immer';
type State = {
loading: boolean;
setLoading: (val: boolean) => null;
screenWidth: number;
setScreenWidth: (val: number) => void;
isPc: boolean;
};
export const useGlobalStore = create<State>()(
@@ -16,7 +19,15 @@ export const useGlobalStore = create<State>()(
state.loading = val;
});
return null;
}
},
screenWidth: 600,
setScreenWidth(val: number) {
set((state) => {
state.screenWidth = val;
state.isPc = val < 900 ? false : true;
});
},
isPc: false
}))
)
);

View File

@@ -2,18 +2,22 @@ import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type { UserType, UserUpdateParams } from '@/types/user';
import type { ModelSchema } from '@/types/mongoSchema';
import { getMyModels, getModelById } from '@/api/model';
import { formatPrice } from '@/utils/user';
import { getTokenLogin } from '@/api/user';
import { defaultModel } from '@/constants/model';
import { ModelListItemType } from '@/types/model';
import { KbItemType } from '@/types/plugin';
import { getKbList, getKbById } from '@/api/plugins/kb';
import { defaultKbDetail } from '@/constants/kb';
import type { ModelSchema } from '@/types/mongoSchema';
type State = {
userInfo: UserType | null;
initUserInfo: () => Promise<null>;
setUserInfo: (user: UserType | null) => void;
updateUserInfo: (user: UserUpdateParams) => void;
// model
lastModelId: string;
setLastModelId: (id: string) => void;
myModels: ModelListItemType[];
@@ -26,6 +30,13 @@ type State = {
updateModelDetail(model: ModelSchema): void;
removeModelDetail(modelId: string): void;
};
// kb
lastKbId: string;
setLastKbId: (id: string) => void;
myKbList: KbItemType[];
loadKbList: (init?: boolean) => Promise<KbItemType[]>;
kbDetail: KbItemType;
getKbDetail: (id: string, init?: boolean) => Promise<KbItemType>;
};
export const useUserStore = create<State>()(
@@ -103,12 +114,40 @@ export const useUserStore = create<State>()(
}
get().loadMyModels(true);
}
},
lastKbId: '',
setLastKbId(id: string) {
set((state) => {
state.lastKbId = id;
});
},
myKbList: [],
async loadKbList(init = false) {
if (get().myKbList.length > 0 && !init) return get().myKbList;
const res = await getKbList();
set((state) => {
state.myKbList = res;
});
return res;
},
kbDetail: defaultKbDetail,
async getKbDetail(id: string, init = false) {
if (id === get().kbDetail._id && !init) return get().kbDetail;
const data = await getKbById(id);
set((state) => {
state.kbDetail = data;
});
return data;
}
})),
{
name: 'userStore',
partialize: (state) => ({
lastModelId: state.lastModelId
lastModelId: state.lastModelId,
lastKbId: state.lastKbId
})
}
)

13
src/types/model.d.ts vendored
View File

@@ -1,5 +1,6 @@
import { ModelStatusEnum } from '@/constants/model';
import type { ModelSchema } from './mongoSchema';
import type { ModelSchema, kbSchema } from './mongoSchema';
import { ChatModelType, ModelVectorSearchModeEnum } from '@/constants/model';
export type ModelListItemType = {
_id: string;
@@ -13,16 +14,6 @@ export interface ModelUpdateParams {
avatar: string;
chat: ModelSchema['chat'];
share: ModelSchema['share'];
security: ModelSchema['security'];
}
export interface ModelDataItemType {
id: string;
status: 'waiting' | 'ready';
q: string; // 提问词
a: string; // 原文
modelId: string;
userId: string;
}
export interface ShareModelItem {

View File

@@ -38,7 +38,7 @@ export interface ModelSchema {
status: `${ModelStatusEnum}`;
updateTime: number;
chat: {
useKb: boolean;
relatedKbs: string[];
searchMode: `${ModelVectorSearchModeEnum}`;
systemPrompt: string;
temperature: number;
@@ -50,13 +50,6 @@ export interface ModelSchema {
intro: string;
collection: number;
};
security: {
domain: string[];
contextMaxLen: number;
contentMaxLen: number;
expiredTime: number;
maxLoadAmount: number;
};
}
export interface ModelPopulate extends ModelSchema {
@@ -69,19 +62,11 @@ export interface CollectionSchema {
}
export type ModelDataType = 0 | 1;
export interface ModelDataSchema {
_id: string;
modelId: string;
userId: string;
a: string;
q: string;
status: ModelDataType;
}
export interface ModelSplitDataSchema {
export interface SplitDataSchema {
_id: string;
userId: string;
modelId: string;
kbId: string;
prompt: string;
errorText: string;
textList: string[];
@@ -148,3 +133,12 @@ export interface ShareChatSchema {
maxContext: number;
lastTime: Date;
}
export interface kbSchema {
_id: string;
userId: string;
updateTime: Date;
avatar: string;
name: string;
tags: string[];
}

7
src/types/pg.d.ts vendored
View File

@@ -1,10 +1,11 @@
import { ModelDataStatusEnum } from '@/constants/model';
export interface PgModelDataItemType {
export interface PgKBDataItemType {
id: string;
q: string;
a: string;
status: `${ModelDataStatusEnum}`;
model_id: string;
user_id: string;
// model_id: string;
// user_id: string;
// kb_id: string;
}

15
src/types/plugin.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
import type { kbSchema } from './mongoSchema';
/* kb type */
export interface KbItemType extends kbSchema {
totalData: number;
tags: string;
}
export interface KbDataItemType {
id: string;
status: 'waiting' | 'ready';
q: string; // 提问词
a: string; // 原文
kbId: string;
userId: string;
}

View File

@@ -89,6 +89,7 @@ export const formatTimeToChatTime = (time: Date) => {
return target.format('YYYY/M/D');
};
export const hasVoiceApi = typeof window !== 'undefined' && 'speechSynthesis' in window;
/**
* voice broadcast
*/
@@ -113,3 +114,15 @@ export const voiceBroadcast = ({ text }: { text: string }) => {
cancel: () => window.speechSynthesis?.cancel()
};
};
export const formatLinkText = (text: string) => {
const httpReg =
/(http|https|ftp):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&amp;:/~\+#]*[\w\-\@?^=%&amp;/~\+#])?/gi;
return text.replace(httpReg, ` $& `);
};
export const getErrText = (err: any, def = '') => {
const msg = typeof err === 'string' ? err : err?.message || def || '';
msg && console.log('error =>', msg);
return msg;
};