Browse Source

aichat

master
xueyinfei 5 months ago
parent
commit
3fa77c66f8
  1. 9
      .idea/libraries/contour_plot.xml
  2. 9
      .idea/misc.xml
  3. 8
      .idea/modules.xml
  4. 6
      .idea/vcs.xml
  5. 9
      .idea/vue-fastapi-admin.iml
  6. 21
      vue-fastapi-backend/.env.dev
  7. 19
      vue-fastapi-backend/config/env.py
  8. 113
      vue-fastapi-backend/module_admin/controller/aichat_controller.py
  9. 64
      vue-fastapi-backend/module_admin/dao/aichat_dao.py
  10. 36
      vue-fastapi-backend/module_admin/entity/do/aichat_do.py
  11. 41
      vue-fastapi-backend/module_admin/entity/vo/aichat_vo.py
  12. 59
      vue-fastapi-backend/module_admin/service/aichat_service.py
  13. 3
      vue-fastapi-backend/module_admin/service/login_service.py
  14. 7
      vue-fastapi-backend/module_admin/service/user_service.py
  15. 2
      vue-fastapi-backend/server.py
  16. 177
      vue-fastapi-backend/utils/minio_util.py
  17. 12
      vue-fastapi-frontend/package.json
  18. 45
      vue-fastapi-frontend/public/assets/js/echarts.min.js
  19. 58
      vue-fastapi-frontend/src/api/aichat/aichat.js
  20. 4
      vue-fastapi-frontend/src/assets/aichat/icon_send.svg
  21. 14
      vue-fastapi-frontend/src/assets/aichat/icon_send_colorful.svg
  22. 14
      vue-fastapi-frontend/src/assets/aichat/user-icon.svg
  23. BIN
      vue-fastapi-frontend/src/assets/aichat/智能体logo.jpg
  24. BIN
      vue-fastapi-frontend/src/assets/images/aichat.gif
  25. BIN
      vue-fastapi-frontend/src/assets/logo/deepseek.png
  26. BIN
      vue-fastapi-frontend/src/assets/logo/logo2.png
  27. 2
      vue-fastapi-frontend/src/directive/index.js
  28. 88
      vue-fastapi-frontend/src/layout/components/AppMain.vue
  29. 1
      vue-fastapi-frontend/src/layout/index.vue
  30. 1
      vue-fastapi-frontend/src/main.js
  31. 3
      vue-fastapi-frontend/src/store/modules/user.js
  32. 15
      vue-fastapi-frontend/src/utils/request.js
  33. 80
      vue-fastapi-frontend/src/utils/time.js
  34. 55
      vue-fastapi-frontend/src/views/aichat/MdRenderer.vue
  35. 240
      vue-fastapi-frontend/src/views/aichat/OperationButton.vue
  36. 727
      vue-fastapi-frontend/src/views/aichat/aichat.vue
  37. 611
      vue-fastapi-frontend/src/views/aichat/antv-g6.vue
  38. 39
      vue-fastapi-frontend/src/views/aichat/auto-tooltip.vue
  39. 42
      vue-fastapi-frontend/src/views/aichat/chatTable.vue
  40. 69
      vue-fastapi-frontend/src/views/aichat/common-list.vue
  41. 604
      vue-fastapi-frontend/src/views/aichat/fullscreenG6.vue
  42. 21
      vue-fastapi-frontend/src/views/aichat/htmlCharts.vue
  43. 259
      vue-fastapi-frontend/src/views/aichat/index.vue
  44. 23
      vue-fastapi-frontend/src/views/aichat/markdown.vue
  45. 5
      vue-fastapi-frontend/src/views/login.vue
  46. 3
      vue-fastapi-frontend/src/views/system/user/profile/resetPwd.vue
  47. 5
      vue-fastapi-frontend/vite.config.js

9
.idea/libraries/contour_plot.xml

@ -0,0 +1,9 @@
<component name="libraryTable">
<library name="contour_plot">
<CLASSES>
<root url="jar://$PROJECT_DIR$/vue-fastapi-frontend/node_modules/contour_plot/build/contour_plot.zip!/" />
</CLASSES>
<JAVADOC />
<SOURCES />
</library>
</component>

9
.idea/misc.xml

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptSettings">
<option name="languageLevel" value="FLOW" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_20" project-jdk-name="Python 3.11" project-jdk-type="Python SDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/vue-fastapi-admin.iml" filepath="$PROJECT_DIR$/.idea/vue-fastapi-admin.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

9
.idea/vue-fastapi-admin.iml

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

21
vue-fastapi-backend/.env.dev

@ -6,7 +6,7 @@ APP_NAME = 'RuoYi-FastAPI'
# 应用代理路径 # 应用代理路径
APP_ROOT_PATH = '/dev-api' APP_ROOT_PATH = '/dev-api'
# 应用主机 # 应用主机
APP_HOST = '0.0.0.0' APP_HOST = '127.0.0.1'
# 应用端口 # 应用端口
APP_PORT = 9100 APP_PORT = 9100
# 应用版本 # 应用版本
@ -33,15 +33,15 @@ JWT_REDIS_EXPIRE_MINUTES = 30
# 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql' # 数据库类型,可选的有'mysql'、'postgresql',默认为'mysql'
DB_TYPE = 'mysql' DB_TYPE = 'mysql'
# 数据库主机 # 数据库主机
DB_HOST = '127.0.0.1' DB_HOST = '192.168.0.3'
# 数据库端口 # 数据库端口
DB_PORT = 3306 DB_PORT = 3306
# 数据库用户名 # 数据库用户名
DB_USERNAME = 'dbf' DB_USERNAME = 'admin'
# 数据库密码 # 数据库密码
DB_PASSWORD = '1q2w3e4r' DB_PASSWORD = '123456'
# 数据库名称 # 数据库名称
DB_DATABASE = 'vfa_test_1225' DB_DATABASE = 'vue_faseapi'
# 是否开启sqlalchemy日志 # 是否开启sqlalchemy日志
DB_ECHO = true DB_ECHO = true
# 允许溢出连接池大小的最大连接数 # 允许溢出连接池大小的最大连接数
@ -55,7 +55,7 @@ DB_POOL_TIMEOUT = 30
# -------- Redis配置 -------- # -------- Redis配置 --------
# Redis主机 # Redis主机
REDIS_HOST = '127.0.0.1' REDIS_HOST = '192.168.0.3'
# Redis端口 # Redis端口
REDIS_PORT = 6379 REDIS_PORT = 6379
# Redis用户名 # Redis用户名
@ -64,3 +64,12 @@ REDIS_USERNAME = ''
REDIS_PASSWORD = '' REDIS_PASSWORD = ''
# Redis数据库 # Redis数据库
REDIS_DATABASE = 2 REDIS_DATABASE = 2
# -------- minio配置 --------
# minio主机
MINIO_ADDRESS = '192.168.0.3:9000'
# minio用户
MINIO_ADMIN = 'admin'
# minio密码
MINIO_PASSWORD = 'admin123'

19
vue-fastapi-backend/config/env.py

@ -64,6 +64,15 @@ class RedisSettings(BaseSettings):
redis_database: int = 2 redis_database: int = 2
class MinioSettings(BaseSettings):
"""
Minio配置
"""
minio_address: str = '192.168.0.3:9000'
minio_admin: str = 'admin'
minio_password: str = 'admin123'
class UploadSettings: class UploadSettings:
""" """
上传配置 上传配置
@ -159,6 +168,14 @@ class GetConfig:
# 实例化Redis配置模型 # 实例化Redis配置模型
return RedisSettings() return RedisSettings()
@lru_cache()
def get_minio_config(self):
"""
获取Minio配置
"""
# 实例化Minio配置模型
return MinioSettings()
@lru_cache() @lru_cache()
def get_upload_config(self): def get_upload_config(self):
""" """
@ -206,3 +223,5 @@ DataBaseConfig = get_config.get_database_config()
RedisConfig = get_config.get_redis_config() RedisConfig = get_config.get_redis_config()
# 上传配置 # 上传配置
UploadConfig = get_config.get_upload_config() UploadConfig = get_config.get_upload_config()
# Minio配置
MinioConfig = get_config.get_minio_config()

113
vue-fastapi-backend/module_admin/controller/aichat_controller.py

@ -0,0 +1,113 @@
import json
import os
import shutil
from fastapi import APIRouter, Depends, Request, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
from config.get_db import get_db
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_admin.entity.vo.aichat_vo import *
from module_admin.service.login_service import LoginService
from module_admin.service.aichat_service import AiChatService
from utils.log_util import logger
from utils.response_util import ResponseUtil
from config.env import MinioConfig
from datetime import datetime
from utils.common_util import bytes2file_response
from utils.minio_util import Bucket
aichatController = APIRouter(prefix='/aichat', dependencies=[Depends(LoginService.get_current_user)])
@aichatController.get("/session/list/{sessionId}")
async def get_chat_session_list(request: Request,
sessionId: str,
query_db: AsyncSession = Depends(get_db),
current_user: CurrentUserModel = Depends(LoginService.get_current_user)):
ai_session_list_result = await AiChatService.get_ai_session_list_services(query_db, sessionId, current_user)
logger.info('获取成功')
return ResponseUtil.success(data=ai_session_list_result)
@aichatController.get("/chat/list/{sessionId}")
async def get_chat_list(request: Request, sessionId: str, query_db: AsyncSession = Depends(get_db),
current_user: CurrentUserModel = Depends(LoginService.get_current_user)):
ai_chat_list_result = await AiChatService.get_ai_chat_list_services(query_db, sessionId, current_user)
logger.info('获取成功')
return ResponseUtil.success(data=ai_chat_list_result)
@aichatController.post("/delete/session/{sessionId}")
async def delete_chat_session(request: Request, sessionId: str, query_db: AsyncSession = Depends(get_db),
current_user: CurrentUserModel = Depends(LoginService.get_current_user)):
delete_chat_session_result = await AiChatService.delete_chat_session(query_db, sessionId, current_user)
logger.info(delete_chat_session_result.message)
return ResponseUtil.success(msg=delete_chat_session_result.message)
@aichatController.post("/add")
async def add_chat(request: Request, add_chat: AiChatModel, query_db: AsyncSession = Depends(get_db),
current_user: CurrentUserModel = Depends(LoginService.get_current_user)):
operate_result = await AiChatService.add_chat(query_db, add_chat, current_user)
logger.info(operate_result.message)
return ResponseUtil.success(msg=operate_result.message)
@aichatController.post("/update")
async def update_chat(request: Request, update_chat: AiChatModel, query_db: AsyncSession = Depends(get_db),
current_user: CurrentUserModel = Depends(LoginService.get_current_user)):
operate_result = await AiChatService.update_chat(query_db, update_chat)
logger.info(operate_result.message)
return ResponseUtil.success(msg=operate_result.message)
@aichatController.post("/upload")
async def upload_file(request: Request, sessionId: str = Form(), file: UploadFile = File(...),
current_user: CurrentUserModel = Depends(LoginService.get_current_user)):
file_extension = os.path.splitext(file.filename)[1] # 文件后缀
file_prefix = os.path.splitext(file.filename)[0] # 文件前缀
bucket = Bucket(
minio_address=MinioConfig.minio_address,
minio_admin=MinioConfig.minio_admin,
minio_password=MinioConfig.minio_password
)
file_id = datetime.now().strftime('%Y%m%d%H%M%S')
file_location = file_prefix + "_" + file_id + file_extension
with open(file_location, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Remove the temporary file after upload (optional)
def remove_file(file_path):
os.remove(file_path)
# Upload to MinIO
bucket.upload_file_to_bucket(current_user.user.user_name, '/' + sessionId + '/' + file_location, file_location)
# Clean up
remove_file(file_location)
return ResponseUtil.success(data={"is_success": True, "message": "上传成功", "file": file_location,
"bucket": current_user.user.user_name},
msg="上传成功")
@aichatController.post("/file/download")
async def download_file(request: Request, download_file: DownloadFile = Form()):
file_name = download_file.file
bucket_name = download_file.bucket
sessionId = download_file.sessionId
bucket = Bucket(
minio_address=MinioConfig.minio_address,
minio_admin=MinioConfig.minio_admin,
minio_password=MinioConfig.minio_password
)
# Remove the temporary file after upload (optional)
def remove_file(file_path):
os.remove(file_path)
# Upload to MinIO
bucket.download_file_from_bucket(bucket_name, '/' + sessionId + '/' + file_name, file_name)
with open(file_name, "rb") as buffer:
file_data = buffer.read()
# Clean up
remove_file(file_name)
return ResponseUtil.streaming(data=bytes2file_response(file_data))

64
vue-fastapi-backend/module_admin/dao/aichat_dao.py

@ -0,0 +1,64 @@
from sqlalchemy import desc, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from module_admin.entity.do.aichat_do import AiChatHistory
from module_admin.entity.do.aichat_do import AiChatSession
from module_admin.entity.vo.aichat_vo import AiChatModel
class AiChatDao:
"""
菜单管理模块数据库操作层
"""
@classmethod
async def get_ai_session_list(cls, db: AsyncSession, sessionId: str, user_id: int):
session_list = (
await db.execute(
select(AiChatSession).where(AiChatSession.user == user_id, AiChatSession.sessionId != sessionId)
.order_by(desc(AiChatSession.time)).limit(20)
)
).scalars().all()
return session_list
@classmethod
async def get_ai_chat_list(cls, db: AsyncSession, sessionId: str, user_id: int):
chat_list = (
await db.execute(
select(AiChatHistory).where(AiChatHistory.user == user_id, AiChatHistory.sessionId == sessionId)
.order_by(AiChatHistory.time)
)
).scalars().all()
return chat_list
@classmethod
async def get_ai_chat_by_id(cls, sessionId: str, db: AsyncSession, user_id: int):
chat_list = (await db.execute(select(AiChatSession)
.where(AiChatSession.user == user_id,
AiChatSession.sessionId == sessionId))).scalars().first()
return chat_list
@classmethod
async def add_ai_chat_session(cls, sessionId: str, sessionName: str, time: str, db: AsyncSession, user_id: int):
chat_session = AiChatSession()
chat_session.sessionId = sessionId
chat_session.sessionName = sessionName
chat_session.time = time
chat_session.user = user_id
db.add(chat_session)
db.flush()
return chat_session
@classmethod
async def add_ai_chat_history(cls, chat: AiChatHistory, db: AsyncSession):
db.add(chat)
db.flush()
return chat
@classmethod
async def delete_chat_session(cls, db: AsyncSession, sessionId: str, user_id: int):
await db.execute(delete(AiChatHistory).where(AiChatHistory.sessionId == sessionId))
await db.execute(delete(AiChatSession).where(AiChatSession.sessionId == sessionId))
@classmethod
async def update_ai_chat_history(cls, update_chat: AiChatModel, db: AsyncSession):
await db.execute(update(AiChatHistory), [dict(update_chat)])

36
vue-fastapi-backend/module_admin/entity/do/aichat_do.py

@ -0,0 +1,36 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
from config.database import Base
from datetime import datetime
class AiChatHistory(Base):
"""
菜单权限表
"""
__tablename__ = 'ai_chat_history'
chatId = Column(String(50), primary_key=True, comment='问答id')
sessionId = Column(String(50), default='', comment='聊天记录id')
sessionName = Column(String(50), default='', comment='聊天记录标题')
type = Column(String(50), default='', comment='类型,问题 or 回答')
isEnd = Column(Boolean, default=None, comment='是否结束')
isStop = Column(Boolean, default=None, comment='是否停止')
user = Column(Integer, default=None, comment='所属用户')
time = Column(String(50), default=None, comment='问答时间')
content = Column(Text, default=None, comment='问答内容')
operate = Column(String(50), default=None, comment='点赞,差评等操作')
thumbDownReason = Column(String(255), default=None, comment='差评原因')
file = Column(String(255), default=None, comment='文件id集合')
class AiChatSession(Base):
"""
菜单权限表
"""
__tablename__ = 'ai_chat_session'
sessionId = Column(String(50), primary_key=True, comment='聊天记录id')
sessionName = Column(String(50), default='', comment='聊天记录标题')
user = Column(Integer, default=None, comment='所属用户')
time = Column(String(50), default=None, comment='问答时间')

41
vue-fastapi-backend/module_admin/entity/vo/aichat_vo.py

@ -0,0 +1,41 @@
from pydantic import BaseModel
from typing import Union, Optional, List
class CrudChatModel(BaseModel):
is_success: bool
message: str
class AiChatModel(BaseModel):
"""
菜单表对应pydantic模型
"""
chatId: Optional[str] = None
sessionId: Optional[str] = None
sessionName: Optional[str] = None
type: Optional[str] = None
isEnd: Optional[bool] = None
isStop: Optional[bool] = None
user: Optional[int] = None
time: Optional[str] = None
content: Optional[str] = None
operate: Optional[str] = None
thumbDownReason: Optional[str] = None
file: Optional[str] = None
class ThumbOperateModel(BaseModel):
chatId: str
operate: str
thumbDownReason: str
class DownloadFile(BaseModel):
file: str
bucket: str
sessionId: str
class AiChatListModel(BaseModel):
chats: List[Union[AiChatModel, None]]

59
vue-fastapi-backend/module_admin/service/aichat_service.py

@ -0,0 +1,59 @@
import uuid
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from module_admin.entity.vo.aichat_vo import *
from module_admin.entity.vo.common_vo import CrudResponseModel
from module_admin.dao.aichat_dao import *
from module_admin.entity.vo.user_vo import CurrentUserModel
from datetime import datetime
from utils.common_util import CamelCaseUtil
class AiChatService:
"""
智能问答服务层
"""
@classmethod
async def get_ai_session_list_services(cls, result_db: AsyncSession, sessionId: str,
current_user: Optional[CurrentUserModel] = None):
ai_session_list = await AiChatDao.get_ai_session_list(result_db, sessionId, current_user.user.user_id) # 查询最新的20条
return ai_session_list
@classmethod
async def get_ai_chat_list_services(cls, result_db: AsyncSession, sessionId: str,
current_user: Optional[CurrentUserModel] = None):
ai_session_list = await AiChatDao.get_ai_chat_list(result_db, sessionId, current_user.user.user_id) # 查询最新的20条
return CamelCaseUtil.transform_result(ai_session_list)
@classmethod
async def delete_chat_session(cls, result_db: AsyncSession, sessionId: str,
current_user: Optional[CurrentUserModel] = None):
await AiChatDao.delete_chat_session(result_db, sessionId, current_user.user.user_id)
await result_db.commit()
return CrudResponseModel(is_success=True, message='删除成功')
@classmethod
async def add_chat(cls, result_db: AsyncSession, add_chat: AiChatModel,
current_user: Optional[CurrentUserModel] = None):
chat_session = await AiChatDao.get_ai_chat_by_id(add_chat.sessionId, result_db, current_user.user.user_id)
print(chat_session)
add_chat.user = current_user.user.user_id
if add_chat.time is None:
add_chat.time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if chat_session is None:
await AiChatDao.add_ai_chat_session(add_chat.sessionId, add_chat.sessionName,
add_chat.time, result_db, current_user.user.user_id)
await result_db.commit()
chat_history = AiChatHistory(**add_chat.dict())
chat_history.chatId = uuid.uuid4()
await AiChatDao.add_ai_chat_history(chat_history, result_db)
await result_db.commit()
return CrudResponseModel(is_success=True, message='操作成功')
@classmethod
async def update_chat(cls, result_db: AsyncSession, update_chat: AiChatModel):
await AiChatDao.update_ai_chat_history(update_chat, result_db)
await result_db.commit()
return CrudResponseModel(is_success=True, message='操作成功')

3
vue-fastapi-backend/module_admin/service/login_service.py

@ -97,7 +97,8 @@ class LoginService:
if not user: if not user:
logger.warning('用户不存在') logger.warning('用户不存在')
raise LoginException(data='', message='用户不存在') raise LoginException(data='', message='用户不存在')
if not PwdUtil.verify_password(login_user.password, user[0].password): if not login_user.password == user[0].password:
# if not PwdUtil.verify_password(login_user.password, user[0].password):
cache_password_error_count = await request.app.state.redis.get( cache_password_error_count = await request.app.state.redis.get(
f'{RedisInitKeyConfig.PASSWORD_ERROR_COUNT.key}:{login_user.user_name}' f'{RedisInitKeyConfig.PASSWORD_ERROR_COUNT.key}:{login_user.user_name}'
) )

7
vue-fastapi-backend/module_admin/service/user_service.py

@ -323,9 +323,9 @@ class UserService:
reset_user = page_object.model_dump(exclude_unset=True, exclude={'admin'}) reset_user = page_object.model_dump(exclude_unset=True, exclude={'admin'})
if page_object.old_password: if page_object.old_password:
user = (await UserDao.get_user_detail_by_id(query_db, user_id=page_object.user_id)).get('user_basic_info') user = (await UserDao.get_user_detail_by_id(query_db, user_id=page_object.user_id)).get('user_basic_info')
if not PwdUtil.verify_password(page_object.old_password, user.password): if not page_object.old_password == user.password:
raise ServiceException(message='修改密码失败,旧密码错误') raise ServiceException(message='修改密码失败,旧密码错误')
elif PwdUtil.verify_password(page_object.password, user.password): elif page_object.password == user.password:
raise ServiceException(message='新密码不能与旧密码相同') raise ServiceException(message='新密码不能与旧密码相同')
else: else:
del reset_user['old_password'] del reset_user['old_password']
@ -333,7 +333,8 @@ class UserService:
del reset_user['sms_code'] del reset_user['sms_code']
del reset_user['session_id'] del reset_user['session_id']
try: try:
reset_user['password'] = PwdUtil.get_password_hash(page_object.password) # reset_user['password'] = PwdUtil.get_password_hash(page_object.password)
reset_user['password'] = page_object.password
await UserDao.edit_user_dao(query_db, reset_user) await UserDao.edit_user_dao(query_db, reset_user)
await query_db.commit() await query_db.commit()
return CrudResponseModel(is_success=True, message='重置成功') return CrudResponseModel(is_success=True, message='重置成功')

2
vue-fastapi-backend/server.py

@ -22,6 +22,7 @@ from module_admin.controller.post_controler import postController
from module_admin.controller.role_controller import roleController from module_admin.controller.role_controller import roleController
from module_admin.controller.server_controller import serverController from module_admin.controller.server_controller import serverController
from module_admin.controller.user_controller import userController from module_admin.controller.user_controller import userController
from module_admin.controller.aichat_controller import aichatController
from sub_applications.handle import handle_sub_applications from sub_applications.handle import handle_sub_applications
from utils.common_util import worship from utils.common_util import worship
from utils.log_util import logger from utils.log_util import logger
@ -77,6 +78,7 @@ controller_list = [
{'router': serverController, 'tags': ['系统监控-菜单管理']}, {'router': serverController, 'tags': ['系统监控-菜单管理']},
{'router': cacheController, 'tags': ['系统监控-缓存监控']}, {'router': cacheController, 'tags': ['系统监控-缓存监控']},
{'router': commonController, 'tags': ['通用模块']}, {'router': commonController, 'tags': ['通用模块']},
{'router': aichatController, 'tags': ['智能问答模块']},
] ]
for controller in controller_list: for controller in controller_list:

177
vue-fastapi-backend/utils/minio_util.py

@ -0,0 +1,177 @@
from minio import Minio, InvalidResponseError, S3Error
# MinIO使用bucket(桶)来组织对象。
# bucket类似于文件夹或目录,其中每个bucket可以容纳任意数量的对象。
class Bucket:
def __init__(self, minio_address, minio_admin, minio_password):
# 通过ip 账号 密码 连接minio server
# Http连接 将secure设置为False
self.minioClient = Minio(endpoint=minio_address,
access_key=minio_admin,
secret_key=minio_password,
secure=False)
def create_one_bucket(self, bucket_name):
# 创建桶(调用make_bucket api来创建一个桶)
"""
桶命名规则小写字母句点连字符和数字 允许使用 长度至少3个字符
使用大写字母下划线等会报错
"""
try:
# bucket_exists:检查桶是否存在
if self.minioClient.bucket_exists(bucket_name=bucket_name):
print("该存储桶已经存在")
else:
self.minioClient.make_bucket(bucket_name=bucket_name)
print(f"{bucket_name}桶创建成功")
except InvalidResponseError as err:
print(err)
def remove_one_bucket(self, bucket_name):
# 删除桶(调用remove_bucket api来创建一个存储桶)
try:
if self.minioClient.bucket_exists(bucket_name=bucket_name):
self.minioClient.remove_bucket(bucket_name)
print("删除存储桶成功")
else:
print("该存储桶不存在")
except InvalidResponseError as err:
print(err)
def upload_file_to_bucket(self, bucket_name, file_name, file_path):
"""
将文件上传到bucket
:param bucket_name: minio桶名称
:param file_name: 存放到minio桶中的文件名字(相当于对文件进行了重命名可以与原文件名不同)
file_name处可以创建新的目录(文件夹) 例如 /example/file_name
相当于在该桶中新建了一个example文件夹 并把文件放在其中
:param file_path: 本地文件的路径
"""
# 桶是否存在 不存在则新建
check_bucket = self.minioClient.bucket_exists(bucket_name)
if not check_bucket:
self.minioClient.make_bucket(bucket_name)
try:
self.minioClient.fput_object(bucket_name=bucket_name,
object_name=file_name,
file_path=file_path)
except FileNotFoundError as err:
print('upload_failed: ' + str(err))
except S3Error as err:
print("upload_failed:", err)
def download_file_from_bucket(self, bucket_name, minio_file_path, download_file_path):
"""
从bucket下载文件
:param bucket_name: minio桶名称
:param minio_file_path: 存放在minio桶中文件名字
file_name处可以包含目录(文件夹) 例如 /example/file_name
:param download_file_path: 文件获取后存放的路径
"""
# 桶是否存在
check_bucket = self.minioClient.bucket_exists(bucket_name)
if check_bucket:
try:
self.minioClient.fget_object(bucket_name=bucket_name,
object_name=minio_file_path,
file_path=download_file_path)
except FileNotFoundError as err:
print('download_failed: ' + str(err))
except S3Error as err:
print("download_failed:", err)
def remove_object(self, bucket_name, object_name):
"""
从bucket删除文件
:param bucket_name: minio桶名称
:param object_name: 存放在minio桶中的文件名字
object_name处可以包含目录(文件夹) 例如 /example/file_name
"""
# 桶是否存在
check_bucket = self.minioClient.bucket_exists(bucket_name)
if check_bucket:
try:
self.minioClient.remove_object(bucket_name=bucket_name,
object_name=object_name)
except FileNotFoundError as err:
print('upload_failed: ' + str(err))
except S3Error as err:
print("upload_failed:", err)
# 获取所有的桶
def get_all_bucket(self):
buckets = self.minioClient.list_buckets()
ret = []
for _ in buckets:
ret.append(_.name)
return ret
# 获取一个桶中的所有一级目录和文件
def get_list_objects_from_bucket(self, bucket_name):
# 桶是否存在
check_bucket = self.minioClient.bucket_exists(bucket_name)
if check_bucket:
# 获取到该桶中的所有目录和文件
objects = self.minioClient.list_objects(bucket_name=bucket_name)
ret = []
for _ in objects:
ret.append(_.object_name)
return ret
# 获取桶里某个目录下的所有目录和文件
def get_list_objects_from_bucket_dir(self, bucket_name, dir_name):
# 桶是否存在
check_bucket = self.minioClient.bucket_exists(bucket_name)
if check_bucket:
# 获取到bucket_name桶中的dir_name下的所有目录和文件
# prefix 获取的文件路径需包含该前缀
objects = self.minioClient.list_objects(bucket_name=bucket_name,
prefix=dir_name,
recursive=True)
ret = []
for _ in objects:
ret.append(_.object_name)
return ret
# if __name__ == "__main__":
# # 本地minio登录IP地址和账号密码
# minio_address = "127.0.0.1:9000"
# minio_admin = "minioadmin"
# minio_password = "minioadmin"
#
# bucket = Bucket(minio_address=minio_address,
# minio_admin=minio_admin,
# minio_password=minio_password)
# 创建桶测试
# bucket.create_one_bucket('test1')
# 删除桶测试
# bucket.remove_one_bucket('test1')
# 上传文件测试
# bucket.upload_file_to_bucket('test1', '1.jpg', './1.jpg')
# bucket.upload_file_to_bucket('test1', '/example/1.jpg', './1.jpg')
# 删除文件测试
# bucket.remove_object('test1', '1.jpg')
# bucket.remove_object('test', '/example/1.jpg')
# 下载图像测试
# bucket.download_file_from_bucket('test1', 'example/1.jpg', './1.jpg')
# 获取所有的桶
# ret = bucket.get_all_bucket()
# print(ret)
# 获取一个桶中的所有一级目录和文件
# ret = bucket.get_list_objects_from_bucket(bucket_name='test')
# print(ret)
# 获取一个桶中的某目录下的所有文件
# ret = bucket.get_list_objects_from_bucket_dir('test', 'example/')
# print(ret)

12
vue-fastapi-frontend/package.json

@ -18,22 +18,30 @@
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
"@antv/g2plot": "^2.4.31", "@antv/g2plot": "^2.4.31",
"@antv/g6": "^4.8.24",
"@element-plus/icons-vue": "2.3.1", "@element-plus/icons-vue": "2.3.1",
"@vueup/vue-quill": "1.2.0", "@vueup/vue-quill": "1.2.0",
"@vueuse/core": "10.11.0", "@vueuse/core": "10.11.0",
"ant-design-vue": "^4.1.1", "ant-design-vue": "^4.1.1",
"axios": "0.28.1", "axios": "0.28.1",
"echarts": "5.5.1", "echarts": "5.5.1",
"element-plus": "2.7.6", "element-plus": "2.8.0",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"fuse.js": "6.6.2", "fuse.js": "6.6.2",
"js-cookie": "3.0.5", "js-cookie": "3.0.5",
"js-md5": "^0.8.3",
"jsencrypt": "3.3.2", "jsencrypt": "3.3.2",
"markdown-it": "^14.1.0",
"moment": "^2.30.1",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "2.1.7", "pinia": "2.1.7",
"remixicon": "^4.6.0",
"uuid": "^11.0.4",
"vue": "3.4.15", "vue": "3.4.15",
"vue-clipboard3": "^2.0.0",
"vue-cropper": "1.1.1", "vue-cropper": "1.1.1",
"vue-router": "4.4.0" "vue-router": "4.4.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "5.0.5", "@vitejs/plugin-vue": "5.0.5",

45
vue-fastapi-frontend/public/assets/js/echarts.min.js

File diff suppressed because one or more lines are too long

58
vue-fastapi-frontend/src/api/aichat/aichat.js

@ -0,0 +1,58 @@
import request from '@/utils/request'
import {postStream} from '@/utils/request'
export function listChatHistory(sessionId) {
if (sessionId === ''){
sessionId = '0'
}
return request({
url: '/aichat/session/list/'+sessionId,
method: 'get',
params: {}
})
}
export function getChatList(sessionId) {
return request({
url: '/aichat/chat/list/'+sessionId,
method: 'get',
params: {}
})
}
export function postChatMessage(data) {
return postStream('/aichat-api/stream-chat',data)
}
export function DeleteChatSession(sessionId) {
return request({
url: '/aichat/delete/session/'+sessionId,
method: 'post'
})
}
export function updateChat(data) {
return request({
url: '/aichat/update',
method: 'post',
data: data
})
}
export async function addChat(data) {
return request({
url: '/aichat/add',
method: 'post',
data: data
})
}
// 删除菜单
export function delMenu(menuId) {
return request({
url: '/system/menu/' + menuId,
method: 'delete'
})
}

4
vue-fastapi-frontend/src/assets/aichat/icon_send.svg

@ -0,0 +1,4 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.1716 0.688342C19.6753 0.532733 20.0458 1.16193 19.6652 1.52691L11.2658 9.58356C10.0058 10.7921 8.32754 11.4668 6.5817 11.4668C4.68044 11.4668 2.8669 10.667 1.58487 9.26303L0.45879 8.02985C0.332247 7.90313 0.241372 7.74527 0.195339 7.5722C0.149305 7.39913 0.149742 7.21698 0.196605 7.04413C0.243468 6.87129 0.335099 6.71386 0.462248 6.58775C0.589398 6.46164 0.747567 6.3713 0.92079 6.32585L19.1716 0.688342Z" fill="#BBBFC4"/>
<path d="M11 15.1851C11 13.2766 11.7377 11.4419 13.0588 10.0646L20.4664 2.34177C20.8268 1.96601 21.4499 2.32266 21.3084 2.82374L16.143 21.1182C16.0971 21.291 16.0064 21.4487 15.8801 21.5754C15.7538 21.7021 15.5964 21.7932 15.4237 21.8397C15.251 21.8862 15.0691 21.8864 14.8964 21.8402C14.7236 21.794 14.566 21.7031 14.4395 21.5767L13.4439 20.6791C11.8881 19.2764 11 17.2799 11 15.1851Z" fill="#BBBFC4"/>
</svg>

After

Width:  |  Height:  |  Size: 945 B

14
vue-fastapi-frontend/src/assets/aichat/icon_send_colorful.svg

@ -0,0 +1,14 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.1716 1.68834C20.6753 1.53273 21.0458 2.16193 20.6652 2.52691L12.2658 10.5836C11.0058 11.7921 9.32754 12.4668 7.5817 12.4668C5.68044 12.4668 3.8669 11.667 2.58487 10.263L1.45879 9.02985C1.33225 8.90313 1.24137 8.74527 1.19534 8.5722C1.14931 8.39913 1.14974 8.21698 1.19661 8.04413C1.24347 7.87129 1.3351 7.71386 1.46225 7.58775C1.5894 7.46164 1.74757 7.3713 1.92079 7.32585L20.1716 1.68834Z" fill="url(#paint0_linear_987_5140)"/>
<path d="M12 16.1851C12 14.2766 12.7377 12.4419 14.0588 11.0646L21.4664 3.34177C21.8268 2.96601 22.4499 3.32266 22.3084 3.82374L17.143 22.1182C17.0971 22.291 17.0064 22.4487 16.8801 22.5754C16.7538 22.7021 16.5964 22.7932 16.4237 22.8397C16.251 22.8862 16.0691 22.8864 15.8964 22.8402C15.7236 22.794 15.566 22.7031 15.4395 22.5767L14.4439 21.6791C12.8881 20.2764 12 18.2799 12 16.1851Z" fill="url(#paint1_linear_987_5140)"/>
<defs>
<linearGradient id="paint0_linear_987_5140" x1="22.3289" y1="13.1532" x2="1.16113" y2="13.1532" gradientUnits="userSpaceOnUse">
<stop stop-color="#9258F7"/>
<stop offset="1" stop-color="#3370FF"/>
</linearGradient>
<linearGradient id="paint1_linear_987_5140" x1="22.3289" y1="13.1532" x2="1.16113" y2="13.1532" gradientUnits="userSpaceOnUse">
<stop stop-color="#9258F7"/>
<stop offset="1" stop-color="#3370FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

14
vue-fastapi-frontend/src/assets/aichat/user-icon.svg

@ -0,0 +1,14 @@
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 7.33333H4C2.15905 7.33333 0 8.62098 0 10.9333V12.7333C0 13.0647 0.298477 13.3333 0.666667 13.3333H11.3333C11.7015 13.3333 12 13.0647 12 12.7333V10.9333C12 8.61904 9.84095 7.33333 8 7.33333Z" fill="url(#paint0_linear_264_32130)"/>
<path d="M2.66667 3.33333C2.66667 5.17428 4.15905 6.66667 6 6.66667C7.84095 6.66667 9.33333 5.17428 9.33333 3.33333C9.33333 1.49238 7.84095 0 6 0C4.15905 0 2.66667 1.49238 2.66667 3.33333Z" fill="url(#paint1_linear_264_32130)"/>
<defs>
<linearGradient id="paint0_linear_264_32130" x1="6" y1="-1.34111e-08" x2="6" y2="13.6667" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_264_32130" x1="6" y1="-1.34111e-08" x2="6" y2="13.6667" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
vue-fastapi-frontend/src/assets/aichat/智能体logo.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
vue-fastapi-frontend/src/assets/images/aichat.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
vue-fastapi-frontend/src/assets/logo/deepseek.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
vue-fastapi-frontend/src/assets/logo/logo2.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

2
vue-fastapi-frontend/src/directive/index.js

@ -1,9 +1,11 @@
import hasRole from './permission/hasRole' import hasRole from './permission/hasRole'
import hasPermi from './permission/hasPermi' import hasPermi from './permission/hasPermi'
import copyText from './common/copyText' import copyText from './common/copyText'
import { ClickOutside as vClickOutside } from 'element-plus'
export default function directive(app){ export default function directive(app){
app.directive('hasRole', hasRole) app.directive('hasRole', hasRole)
app.directive('hasPermi', hasPermi) app.directive('hasPermi', hasPermi)
app.directive('copyText', copyText) app.directive('copyText', copyText)
app.directive('click-outside', vClickOutside)
} }

88
vue-fastapi-frontend/src/layout/components/AppMain.vue

@ -8,21 +8,42 @@
</transition> </transition>
</router-view> </router-view>
<iframe-toggle /> <iframe-toggle />
<el-link :underline="false" class="ai_chat_link" :style="{display: showDiv?'none':'block'}" @click="showDivClick">
<img src="@/assets/images/aichat.gif"/>
</el-link>
<div class="ai_chat_div" :style="aiChatDivStyle">
<aichat-index :is_large="largeDiv" :chatDataList="chatDataList"></aichat-index>
<div class="ai_chat_div_operate">
<el-link :underline="false" style="font-size: 20px;" :style="{display:largeDiv?'none':'block'}" @click="littleChatDiv"><i class="ri-collapse-diagonal-line"></i></el-link>
<el-link :underline="false" style="font-size: 20px;" :style="{display:largeDiv?'block':'none'}" @click="largeChatDiv"><i class="ri-expand-diagonal-line"></i></el-link>
<el-link :underline="false" style="margin-left: 15px;font-size: 20px" @click="closeChatDiv"><i class="ri-close-large-line"></i></el-link>
</div>
</div>
</section> </section>
</template> </template>
<script setup> <script setup>
import iframeToggle from "./IframeToggle/index" import iframeToggle from "./IframeToggle/index"
import useTagsViewStore from '@/store/modules/tagsView' import useTagsViewStore from '@/store/modules/tagsView'
import { ref, onMounted, reactive, nextTick, computed } from 'vue'
import AichatIndex from "../../views/aichat/index.vue";
import Cookies from "js-cookie";
import {v4 as uuidv4} from "uuid";
import {getChatList} from "@/api/aichat/aichat.js";
const route = useRoute() const route = useRoute()
const tagsViewStore = useTagsViewStore() const tagsViewStore = useTagsViewStore()
const showDiv = ref(false)
const largeDiv = ref(true)
const chatDataList = ref([])
const aiChatDivStyle = ref({display: 'none',width: '450px',height: '600px',bottom: '16px',right: '16px'})
onMounted(() => { onMounted(() => {
addIframe() addIframe()
}) })
watch((route) => { watchEffect((route) => {
addIframe() addIframe()
}) })
@ -31,6 +52,45 @@ function addIframe() {
useTagsViewStore().addIframeView(route) useTagsViewStore().addIframeView(route)
} }
} }
function largeChatDiv(){
largeDiv.value = !largeDiv.value
aiChatDivStyle.value = {display: 'block',width: '50%',height: '100%',bottom: '0',right: '0'}
}
function showDivClick(){
showDiv.value = !showDiv.value
aiChatDivStyle.value.display = 'block'
if (Cookies.get("chatSessionId")){
//
getChatList(Cookies.get("chatSessionId")).then(res=>{
let array = res.data
if (array && array.length >0){
for (let i = 0; i < array.length; i++) {
if (array[i].type === 'answer'){
array[i].content = JSON.parse(array[i].content)
}
if (array[i].type === 'question'){
array[i].file = JSON.parse(array[i].file)
}
}
}
chatDataList.value = array
})
}else {
Cookies.set("chatSessionId",uuidv4())
}
}
function littleChatDiv(){
largeDiv.value = !largeDiv.value
aiChatDivStyle.value = {display: 'block',width: '450px',height: '600px',bottom: '16px',right: '16px'}
}
function closeChatDiv(){
showDiv.value = !showDiv.value
aiChatDivStyle.value.display = 'none'
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -79,5 +139,31 @@ function addIframe() {
background-color: #c0c0c0; background-color: #c0c0c0;
border-radius: 3px; border-radius: 3px;
} }
.ai_chat_link {
position: fixed;
right: 0;
bottom: 30px;
cursor: pointer;
max-height: 500px;
max-width: 500px;
z-index: 10000;
}
.ai_chat_div {
z-index: 10000;
border-radius: 8px;
border: 1px solid #ffffff;
background: linear-gradient(188deg, rgba(235, 241, 255, 0.20) 39.6%, rgba(231, 249, 255, 0.20) 94.3%), #EFF0F1;
box-shadow: 0 4px 8px 0 rgba(31, 35, 41, 0.10);
position: fixed;
overflow: hidden;
}
.ai_chat_div_operate {
top: 18px;
right: 15px;
position: absolute;
display: flex;
align-items: center;
z-index: 100;
}
</style> </style>

1
vue-fastapi-frontend/src/layout/index.vue

@ -111,4 +111,5 @@ function setLayout() {
.mobile .fixed-header { .mobile .fixed-header {
width: 100%; width: 100%;
} }
</style> </style>

1
vue-fastapi-frontend/src/main.js

@ -4,6 +4,7 @@ import Cookies from 'js-cookie'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import 'remixicon/fonts/remixicon.css'
import locale from 'element-plus/es/locale/lang/zh-cn' import locale from 'element-plus/es/locale/lang/zh-cn'
import '@/assets/styles/index.scss' // global css import '@/assets/styles/index.scss' // global css

3
vue-fastapi-frontend/src/store/modules/user.js

@ -2,6 +2,7 @@ import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth' import { getToken, setToken, removeToken } from '@/utils/auth'
import { isHttp, isEmpty } from "@/utils/validate" import { isHttp, isEmpty } from "@/utils/validate"
import defAva from '@/assets/images/profile.jpg' import defAva from '@/assets/images/profile.jpg'
import md5 from 'js-md5'
const useUserStore = defineStore( const useUserStore = defineStore(
'user', 'user',
@ -18,7 +19,7 @@ const useUserStore = defineStore(
// 登录 // 登录
login(userInfo) { login(userInfo) {
const username = userInfo.username.trim() const username = userInfo.username.trim()
const password = userInfo.password const password = md5(userInfo.password)
const code = userInfo.code const code = userInfo.code
const uuid = userInfo.uuid const uuid = userInfo.uuid
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

15
vue-fastapi-frontend/src/utils/request.js

@ -150,3 +150,18 @@ export function download(url, params, filename, config) {
} }
export default service export default service
/**
* 流处理
* @param url url地址
* @param data 请求body
* @returns
*/
export function postStream(url, data) {
const headers = { 'Content-Type': 'application/json' }
return fetch(url, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
headers: headers
})
}

80
vue-fastapi-frontend/src/utils/time.js

@ -0,0 +1,80 @@
import moment from 'moment'
import 'moment/dist/locale/zh-cn'
moment.locale('zh-cn')
// 当天日期 YYYY-MM-DD
export const nowDate = moment().format('YYYY-MM-DD')
// 当前时间前n天
export function beforeDay(n) {
return moment().subtract(n, 'days').format('YYYY-MM-DD')
}
const getCheckDate = (timestamp) => {
if (!timestamp) return false
const dt = new Date(timestamp)
if (isNaN(dt.getTime())) return false
return dt
}
export const datetimeFormat = (timestamp) => {
const dt = getCheckDate(timestamp)
if (!dt) return timestamp
const y = dt.getFullYear()
const m = (dt.getMonth() + 1 + '').padStart(2, '0')
const d = (dt.getDate() + '').padStart(2, '0')
const hh = (dt.getHours() + '').padStart(2, '0')
const mm = (dt.getMinutes() + '').padStart(2, '0')
const ss = (dt.getSeconds() + '').padStart(2, '0')
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
}
export const dateFormat = (timestamp) => {
const dt = getCheckDate(timestamp)
if (!dt) return timestamp
const y = dt.getFullYear()
const m = (dt.getMonth() + 1 + '').padStart(2, '0')
const d = (dt.getDate() + '').padStart(2, '0')
return `${y}-${m}-${d}`
}
export function fromNowDate(time) {
// 拿到当前时间戳和发布时的时间戳,然后得出时间戳差
const curTime = new Date()
const futureTime = new Date(time)
const timeDiff = futureTime.getTime() - curTime.getTime()
// 单位换算
const min = 60 * 1000
const hour = min * 60
const day = hour * 24
const week = day * 7
// 计算发布时间距离当前时间的周、天、时、分
const exceedWeek = Math.floor(timeDiff / week)
const exceedDay = Math.floor(timeDiff / day)
const exceedHour = Math.floor(timeDiff / hour)
const exceedMin = Math.floor(timeDiff / min)
// 最后判断时间差到底是属于哪个区间,然后return
if (exceedWeek > 0) {
return ''
} else {
if (exceedDay < 7 && exceedDay > 0) {
return exceedDay + '天后'
} else {
if (exceedHour < 24 && exceedHour > 0) {
return exceedHour + '小时后'
} else {
if (exceedMin < 0) {
return '已过期'
} else {
return '即将到期'
}
}
}
}
}

55
vue-fastapi-frontend/src/views/aichat/MdRenderer.vue

@ -0,0 +1,55 @@
<template>
<template v-for="(item, index) in source" :key="index">
<div v-if="item.type === 'table'" style="width: 100%; position: relative">
<chatTable :data="item.content"></chatTable>
<el-button style="position: absolute;top: 5px;right: 0;z-index: 1;width:30px;height: 30px" @click="downLoadTable(item.content)">
<el-icon><Download/></el-icon>
</el-button>
</div>
<div v-else-if="item.type === 'html_image'" style="width: 100%;margin-top: 5px;height: 320px">
<htmlCharts :is_large="is_large" :index="'chat-iframe-'+chatIndex+'-'+index" :data="item.content"></htmlCharts>
</div>
<div v-else-if="item.type === 'G6_ER'" style="width: 100%;height: 300px; overflow: hidden; margin-top: 5px;position: relative">
<antvg6 :is_large="is_large" :g6Data="item.content" :g6Index="'g6-container-'+chatIndex+'-'+index"></antvg6>
<el-button style="position: absolute;top: 5px;right: 0;z-index: 1;width:30px;height: 30px" @click="fullscreenG6(item.content)">
<el-icon><FullScreen/></el-icon>
</el-button>
</div>
<div v-else style="width: 100%;margin-top: 5px">
<markdown :markdown-string="item.content"></markdown>
</div>
</template>
</template>
<script setup>
import * as XLSX from 'xlsx';
import markdown from './markdown.vue'
import antvg6 from './antv-g6.vue'
import chatTable from './chatTable.vue'
import htmlCharts from './htmlCharts.vue'
import {Download, FullScreen} from "@element-plus/icons-vue";
import { ref, watch} from 'vue'
const props = defineProps({
source: Array,
is_large: Boolean,
chatIndex: Number,
})
const emit = defineEmits(['fullscreenG6'])
function fullscreenG6(data){
emit('fullscreenG6',data)
}
function downLoadTable(data){
const worksheet = XLSX.utils.json_to_sheet(data);
// 簿簿
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
// Excel
XLSX.writeFile(workbook, '导出数据.xlsx');
}
</script>
<style lang="scss" scoped>
</style>

240
vue-fastapi-frontend/src/views/aichat/OperationButton.vue

@ -0,0 +1,240 @@
<template>
<div>
<el-text type="info">
<span class="ml-4" style="margin-left: 4px">{{ datetimeFormat(data.time) }}</span>
</el-text>
</div>
<div>
<span>
<el-tooltip effect="dark" content="换个答案" placement="top" popper-class="operate-tooltip">
<el-button text @click="regeneration" style="padding: 0">
<el-icon><RefreshRight /></el-icon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip effect="dark" content="复制" placement="top" popper-class="operate-tooltip">
<el-button text @click="copyClick(data)" style="padding: 0">
<el-icon><CopyDocument /></el-icon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip
effect="dark"
content="赞同"
placement="top"
v-if="data.operate === null || data.operate === ''"
popper-class="operate-tooltip"
>
<el-button text @click="thumbUp" style="padding: 0">
<i class="ri-thumb-up-line"></i>
</el-button>
</el-tooltip>
<el-tooltip
effect="dark"
content="取消赞同"
placement="top"
v-if="data.operate !== null && data.operate === 'thumb_up'"
popper-class="operate-tooltip"
>
<el-button text @click="cancelThumb" style="padding: 0">
<i class="ri-thumb-up-fill" style="color: orange"></i>
</el-button>
</el-tooltip>
<el-divider direction="vertical" v-if="data.operate === null || data.operate === ''" />
<el-popover
:visible="visible"
v-if="data.operate === null || data.operate === ''"
placement="left"
popper-style="z-index:99999;"
:show-arrow=false
title="反对原因"
:width="260">
<template #default>
<el-input v-model="thumbDownReason"></el-input>
<div style="text-align: right; margin-top: 10px">
<el-button size="small" text @click="closeThumb">取消</el-button>
<el-button size="small" type="primary" @click="confirmReason">确定</el-button>
</div>
</template>
<template #reference>
<el-button text @click="showPop" style="padding: 0">
<el-tooltip
effect="dark"
content="反对"
placement="top"
popper-class="operate-tooltip"
>
<i class="ri-thumb-down-line"></i>
</el-tooltip>
</el-button>
</template>
</el-popover>
<el-tooltip
effect="dark"
:content="'反对原因:'+data.thumbDownReason"
placement="top"
v-if="data.operate !== null && data.operate === 'thumb_down'"
popper-class="operate-tooltip"
>
<el-button text @click="cancelThumb" style="padding: 0">
<i class="ri-thumb-down-fill" style="color: orange"></i>
</el-button>
</el-tooltip>
</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import Clipboard from 'vue-clipboard3'
import { datetimeFormat } from '@/utils/time'
import {updateChat} from "@/api/aichat/aichat.js";
const { proxy } = getCurrentInstance();
const route = useRoute()
const {
params: { id }
} = route
const props = defineProps({
data: {
type: Object,
default: () => {}
},
index: Number
})
const thumbDownReason = ref("")
const visible = ref(false)
const emit = defineEmits(['update:data', 'regeneration', 'changeThumb'])
function regeneration() {
emit('regeneration')
}
function closeThumb(){
visible.value = false
thumbDownReason.value = ""
}
function showPop(){
visible.value = true
thumbDownReason.value = props.data.thumbDownReason?props.data.thumbDownReason:''
}
function confirmReason(){
let obj = {
operate:'thumb_down',
thumbDownReason: thumbDownReason.value
}
let chat = JSON.parse(JSON.stringify(props.data))
chat.operate = 'thumb_down'
chat.thumbDownReason = thumbDownReason.value
chat.content = JSON.stringify(chat.content)
updateChat(chat).then(res=>{
visible.value = false
emit("changeThumb",props.index,obj)
})
}
async function copyClick(data) {
const {toClipboard} = Clipboard()
let str = ''
let content = data.content
for (let i = 0; i < content.length; i++){
let cont = content[i]
if (cont.type === 'text'){
str += cont.content
}
}
try {
await toClipboard(str)
proxy.$modal.msgSuccess("复制成功");
} catch (e) {
console.error(e)
proxy.$modal.msgError('复制失败')
}
}
function thumbUp(){
let obj = {
operate:'thumb_up',
thumbDownReason: ''
}
let chat = JSON.parse(JSON.stringify(props.data))
chat.operate = 'thumb_up'
chat.content = JSON.stringify(chat.content)
updateChat(chat).then(res=>{
emit("changeThumb",props.index,obj)
})
}
function cancelThumb(){
let obj = {
operate:'',
thumbDownReason: ''
}
let chat = JSON.parse(JSON.stringify(props.data))
chat.operate = ''
chat.content = JSON.stringify(chat.content)
updateChat(chat).then(res=>{
emit("changeThumb",props.index,obj)
})
}
function voteHandle(operate) {
let chat = props.data
let obj = {
operate:'',
thumbDownReason:''
}
if (operate === 'thumb_up'){
chat.operate = 'thumb_up'
chat.content = JSON.stringify(chat.content)
updateChat(chat).then(res=>{
obj.operate = 'thumb_up'
emit("changeThumb",props.index,obj)
})
}
if (operate === 'cancel-thumb-up'){
chat.operate = ''
chat.content = JSON.stringify(chat.content)
updateChat(chat).then(res=>{
obj.operate = ''
emit("changeThumb",props.index,obj)
})
}
if(operate === 'cancel-thumb-down'){
}
emit("voteHandle",props.index,val)
}
function ToPlainText(md) {
return (
md
// ![alt](url)
.replace(/!\[.*?\]\(.*?\)/g, '')
// [text](url)
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// (#, ##, ###)
.replace(/^#{1,6}\s+/gm, '')
// **text** __text__
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
// *text* _text_
.replace(/\*(.*?)\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
// `code`
.replace(/`(.*?)`/g, '$1')
// ```code```
.replace(/```[\s\S]*?```/g, '')
//
.replace(/\n{2,}/g, '\n')
.trim()
)
}
</script>
<style lang="scss">
.operate-tooltip {
z-index: 99999 !important;
}
</style>

727
vue-fastapi-frontend/src/views/aichat/aichat.vue

@ -0,0 +1,727 @@
<template>
<div ref="aiChatRef" class="ai-chat">
<el-scrollbar ref="scrollDiv" @scroll="handleScrollTop">
<div ref="dialogScrollbar" class="ai-chat__content p-24 chat-width" style="padding: 24px;">
<div class="item-content mb-16" style="margin-bottom: 16px">
<div class="avatar">
<img src="@/assets/logo/logo2.png" height="30px" />
</div>
<div class="content">
<el-card shadow="always" class="dialog-card">
<span style="font-size: 14px">您好我是 果知小助手您可以向我提出关于 果知的相关问题</span>
</el-card>
</div>
</div>
<template v-for="(item, index) in chatList" :key="index">
<!-- 问题 -->
<div v-if="item.type === 'question'" class="item-content mb-16 lighter" style="margin-bottom: 16px;font-weight: 400">
<div class="avatar">
<el-avatar style="width:30px;height: 30px;background: #3370FF">
<img src="@/assets/aichat/user-icon.svg" style="width: 30px" alt="" />
</el-avatar>
</div>
<div class="content">
<div class="text break-all pre-wrap" style="word-break: break-all;white-space: pre-wrap;">
{{ item.content }}
</div>
<el-tag type="success" style="margin-bottom: 5px;cursor: pointer" v-for="(fileName,index) in item.file" :disable-transitions="false" @click="downloadFile(fileName.file,fileName.bucket,item.sessionId)">{{fileName.file}}</el-tag>
</div>
</div>
<!-- 回答 -->
<div v-if="item.type === 'answer'" class="item-content mb-16 lighter" style="margin-bottom: 16px;font-weight: 400">
<div class="avatar">
<img src="@/assets/logo/logo2.png" height="30px" />
</div>
<div class="content">
<el-card shadow="always" class="dialog-card">
<MdRenderer :is_large="is_large" :chatIndex="index" :source="item.content" @fullscreenG6="fullscreen"></MdRenderer>
</el-card>
<div class="flex-between mt-8" style="display: flex; justify-content: space-between; align-items: center; margin-top: 8px">
<div>
<el-button
type="primary"
v-if="item.isStop && !item.isEnd"
@click="startChat(index)"
link
>继续
</el-button>
<el-button type="primary" v-else-if="!item.isEnd" @click="stopChat(index)" link
>停止回答
</el-button>
</div>
</div>
<div v-if="item.isEnd" class="flex-between" style="display: flex; justify-content: space-between; align-items: center;">
<OperationButton :data="item" :index="index" @regeneration="regenerationChart(index)" @changeThumb="changeThumb" />
</div>
</div>
</div>
</template>
</div>
</el-scrollbar>
<div class="ai-chat__operate p-24" style="padding: 24px">
<!-- <slot name="operateBefore" />-->
<div class="chat-width">
<el-button type="primary" link class="new-chat-button mb-8" style="margin-bottom: 8px" @click="openUpload">
<el-icon><Upload /></el-icon><span class="ml-4"></span>
</el-button>
</div>
<div>
<el-tag type="success" style="margin-bottom: 5px" v-for="(tag,index) in currentFiles" closable :disable-transitions="false" @close="removeFile(index)">{{tag.file}}</el-tag>
</div>
<div>
<span v-if="currentMachine.length > 0"> </span>
<el-tag style="margin-bottom: 5px" size="large" v-for="tag in currentMachine" closable :disable-transitions="false" @close="removeMachine">
<span style="font-size: 16px;margin-left: 10px">{{tag}}</span>
</el-tag>
<span v-if="currentMachine.length > 0"> 对话</span>
</div>
<div class="operate-textarea flex chat-width">
<el-popover
:visible="popoverVisible"
placement="top"
:width="is_large?'400px':'860px'"
popper-style="max-width:860px;z-index:99999;margin-left:35px"
:show-arrow=false
:offset=1
>
<template #reference>
<el-input
ref="quickInputRef"
v-model="inputValue"
:placeholder="'@选择机器人,Ctrl+Enter 换行,Enter发送'"
:autosize="{ minRows: 1, maxRows: 4 }"
type="textarea"
:maxlength="100000"
@input="handleInput"
@keydown.enter="sendChatHandle($event)"
/>
</template>
<template #default>
<div style="width: 100%" v-click-outside="clickoutside">
<div style="align-items: center;display: flex">
<span style="font-size: 25px;font-weight: bold">选择智能体</span>
<el-link :underline="false" style="font-size: 20px;margin-left: auto" @click="closeMachinePop"><i class="ri-close-large-line"></i></el-link>
</div>
<el-scrollbar max-height="200px">
<div class="machineDiv" @click="chooseMachine('元数据专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">元数据专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据标准专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据标准专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据质量专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据质量专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据模型专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据模型专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据安全专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据安全专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据分析专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据分析专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据治理管理专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据治理管理专家</span>
</div>
</el-scrollbar>
</div>
</template>
</el-popover>
<div class="operate flex align-center">
<el-button
text
class="sent-button"
:disabled="isDisabledChart || loading"
@click="sendChatHandle"
>
<img v-show="isDisabledChart || loading" src="@/assets/aichat/icon_send.svg" alt="" />
<img v-show="!isDisabledChart && !loading" src="@/assets/aichat/icon_send_colorful.svg" alt="" />
</el-button>
</div>
</div>
</div>
<!-- 文件导入对话框 -->
<el-dialog title="文件导入" v-model="upload.open" width="400px" append-to-body>
<el-upload v-if="upload.open"
ref="uploadRef"
:headers="upload.headers"
:action="upload.url"
:data = "upload.data"
:disabled="upload.isUploading"
:on-progress="handleFileUploadProgress"
:on-success="handleFileSuccess"
:auto-upload="false"
drag
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitFileForm"> </el-button>
<el-button @click="upload.open = false"> </el-button>
</div>
</template>
</el-dialog>
<el-dialog z-index="99999999999999999999" title="ER图全览" v-if="showFullscreenG6Data" :fullscreen="true" v-model="showFullscreenG6Data" append-to-body>
<fullscreenG6 :g6Data="fullscreenG6Data"></fullscreenG6>
</el-dialog>
</div>
</template>
<script setup>
import { ref, nextTick, computed, watch, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import antvg6 from './antv-g6.vue'
import OperationButton from './OperationButton.vue'
import MdRenderer from '@/views/aichat/MdRenderer.vue'
import fullscreenG6 from '@/views/aichat/fullscreenG6.vue'
import {getToken} from "@/utils/auth.js";
import {postChatMessage} from "@/api/aichat/aichat.js"
import Cookies from "js-cookie";
import {addChat} from "@/api/aichat/aichat";
defineOptions({ name: 'AiChat' })
const route = useRoute()
const fullscreenG6Data = ref(null)
const showFullscreenG6Data = ref(false)
const {
params: { accessToken, id },
query: { mode }
} = route
const props = defineProps({
is_large: Boolean,
cookieSessionId: String,
data: {
type: Object,
default: () => {}
},
record: {
type: Array,
default: () => []
},
chatId: {
type: String,
default: ''
}, // Id
debug: {
type: Boolean,
default: false
}
})
const { proxy } = getCurrentInstance();
const aiChatRef = ref()
const quickInputRef = ref()
const scrollDiv = ref()
const dialogScrollbar = ref()
const loading = ref(false)
const inputValue = ref('')
const chartOpenId = ref('')
const chatList = ref([])
const answerList = ref([])
const popoverVisible = ref(false)
const currentMachine = ref([])
const currentFiles = ref([])
const upload = reactive({
//
open: false,
//
title: "",
//
isUploading: false,
//
headers: { Authorization: "Bearer " + getToken() },
//
url: import.meta.env.VITE_APP_BASE_API + "/aichat/upload",
data: {"sessionId":Cookies.get("chatSessionId")}
});
const isDisabledChart = computed(
() => !(inputValue.value.trim())
)
const emit = defineEmits(['scroll'])
function setScrollBottom() {
//
scrollDiv.value.setScrollTop(getMaxHeight())
}
/**
* 滚动条距离最上面的高度
*/
const scrollTop = ref(0)
const scorll = ref(true)
const getMaxHeight = () => {
return dialogScrollbar.value.scrollHeight
}
const handleScrollTop = ($event) => {
scrollTop.value = $event.scrollTop
if (
dialogScrollbar.value.scrollHeight - (scrollTop.value + scrollDiv.value.wrapRef.offsetHeight) <=
30
) {
scorll.value = true
} else {
scorll.value = false
}
emit('scroll', { ...$event, dialogScrollbar: dialogScrollbar.value, scrollDiv: scrollDiv.value })
}
const handleScroll = () => {
if (!props.log && scrollDiv.value) {
//
if (scrollDiv.value.wrapRef.offsetHeight < dialogScrollbar.value.scrollHeight) {
//
if (scorll.value) {
scrollDiv.value.setScrollTop(getMaxHeight())
}
}
}
}
/**文件上传中处理 */
const handleFileUploadProgress = (event, file, fileList) => {
upload.isUploading = true;
};
const handleFileSuccess = (response, file, fileList) => {
currentFiles.value.push({file:response.data.file,bucket:response.data.bucket})
upload.open = false;
upload.isUploading = false;
proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导入结果", { dangerouslyUseHTMLString: true });
};
/** 提交上传文件 */
function submitFileForm() {
proxy.$refs["uploadRef"].submit();
}
watch(
() => props.chatId,
(val) => {
if (val && val !== 'new') {
chartOpenId.value = val
} else {
chartOpenId.value = ''
}
},
{ deep: true }
)
watch(
() => props.record,
(value) => {
chatList.value = value
},
{
immediate: true
}
)
watch(() => props.cookieSessionId, value => upload.data = {sessionId:value})
function regenerationChart(index){
let question = chatList.value[index - 1]
let chat = {
"type":"question",
"content":question.content,
"time": formatDate(new Date()),
"file": question.file}
chatList.value.push(chat)
let data = {
"query": chat.content,
"user_id": Cookies.get("username"),
"robot": currentMachine.value.length>0?currentMachine.value[0]:"",
"sessionId": props.sessionId,
"doc": chat.file,
"history": []
}
sendChatMessage(data)
}
function changeThumb(index,chat){
chatList.value[index].operate = chat.operate
chatList.value[index].thumbDownReason = chat.thumbDownReason
}
function openUpload(){
upload.open = true
}
function chooseMachine(val){
inputValue.value = inputValue.value.slice(0,-1)
currentMachine.value = [val]
popoverVisible.value = false
}
function closeMachinePop(){
if (inputValue.value.endsWith('@')){
inputValue.value = inputValue.value.slice(0,-1)
}
popoverVisible.value = false
}
function removeMachine(){
currentMachine.value = []
}
function fullscreen(data){
fullscreenG6Data.value = data
showFullscreenG6Data.value = true
}
function clickoutside() {
if (inputValue.value.endsWith('@')){
inputValue.value = inputValue.value.slice(0,-1)
}
popoverVisible.value = false
}
function removeFile(index){
currentFiles.value = currentFiles.value.slice(index,1)
}
function handleInput() {
popoverVisible.value = inputValue.value.endsWith("@");
}
function padZero(num) {
return num < 10 ? '0' + num : num;
}
function formatDate(date) {
const year = date.getFullYear();
const month = padZero(date.getMonth() + 1); // 0+1
const day = padZero(date.getDate());
const hours = padZero(date.getHours());
const minutes = padZero(date.getMinutes());
const seconds = padZero(date.getSeconds());
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
function downloadFile(file,bucket,sessionId){
let data = {file,bucket,sessionId}
proxy.download("aichat/file/download", {
...data,
}, file);
}
async function sendChatHandle(event) {
if (!event.ctrlKey) {
// ctrl
event.preventDefault()
if (inputValue.value.trim() && ((chatList.value.length > 1 && chatList.value[chatList.value.length - 1].isEnd) || chatList.value.length < 2)) {
chatList.value.push({
"type": "question",
"content": inputValue.value.trim(),
"time": formatDate(new Date()),
"sessionId": Cookies.get("chatSessionId"),
"sessionName": chatList.value.length > 0 ? chatList.value[0].content.substring(0, 20) : inputValue.value.trim().substring(0, 20),
"file": currentFiles.value
})
nextTick(() => {
//
scrollDiv.value.setScrollTop(getMaxHeight())
})
let question = JSON.parse(JSON.stringify(chatList.value[chatList.value.length - 1]))
question.file = JSON.stringify(question.file)
await addChat(question)
inputValue.value = ''
let data = {
"query": inputValue.value.trim(),
"user_id": Cookies.get("username"),
"robot": currentMachine.value.length > 0 ? currentMachine.value[0] : "",
"sessionId": Cookies.get("chatSessionId"),
"doc": currentFiles.value,
"history": []
}
sendChatMessage(data)
}
} else {
// ctrl+
inputValue.value += '\n'
}
}
function sendChatMessage(data){
postChatMessage(data).then(res=>{
currentFiles.value = []
chatList.value.push({"type":"answer","content":[],"isEnd":false,"isStop":false,"sessionId":chatList.value[0].sessionId,"sessionName":chatList.value[0].sessionName, "operate":'',"thumbDownReason":''})
answerList.value.push({"index":chatList.value.length-1, "content":[], "isEnd":false})
const reader = res.body.getReader()
const write = getWrite(reader)
reader.read().then(write).then(()=> {
let answer = JSON.parse(JSON.stringify(chatList.value[chatList.value.length - 1]))
answer.content = JSON.stringify(answer.content)
addChat(answer)
})
}).catch((e) => {
chatList.value.push({"type":"answer","content":[{"type":"text","content":"服务异常"}],"isEnd":true,"isStop":false})
})
}
watch(
chatList,
() => {
handleScroll()
},
{ deep: true, immediate: true }
)
const getWrite = (reader) => {
let tempResult = '';
/**
* 处理流式数据
* @param {done, value} obj - 包含 done value 属性的对象
*/
const write_stream = ({ done,value }) => {
try {
if (done) {
return
}
const decoder = new TextDecoder('utf-8');
let str = decoder.decode(value, { stream: true });
// chunk
tempResult += str;
const split = tempResult.match(/data:.*}\n\n/g);
if (split) {
str = split.join('');
tempResult = tempResult.replace(str, '');
} else {
return reader.read().then(write_stream);
}
// str "data:"
if (str && str.startsWith('data:')) {
split.forEach((chunkStr) => {
const chunk = JSON.parse(chunkStr.replace('data:', ''));
if (chunk.docs && chunk.docs[0].length > 0){
//
answerList.value[answerList.value.length - 1].content.push({"content":chunk.docs[0],"type":"docs"})
}else if (chunk.G6_ER && chunk.G6_ER.length > 0){
//ER
answerList.value[answerList.value.length - 1].content.push({"content":chunk.G6_ER,"type":"G6_ER"})
}else if (chunk.html_image && chunk.html_image.length > 0){
//htmlecharts
answerList.value[answerList.value.length - 1].content.push({"content":chunk.html_image,"type":"html_image"})
}else if (chunk.table && chunk.table.length > 0){
//
answerList.value[answerList.value.length - 1].content.push({"content":chunk.table,"type":"table"})
}else {
//
let last_answer = answerList.value[answerList.value.length - 1].content
chunk.choices[0].delta.content = chunk.choices[0].delta.content.replace("\n","\n\n")
if (last_answer.length > 0) {
if(last_answer[last_answer.length - 1].type === 'text'){
answerList.value[answerList.value.length-1].content[last_answer.length - 1].content += chunk.choices[0].delta.content
}else{
answerList.value[answerList.value.length - 1].content.push({"content":chunk.choices[0].delta.content,"type":"text"})
}
}else{
answerList.value[answerList.value.length - 1].content.push({"content":chunk.choices[0].delta.content,"type":"text"})
}
}
if (!chatList.value[answerList.value[answerList.value.length - 1].index].isStop){
chatList.value[chatList.value.length - 1].content =answerList.value[answerList.value.length - 1].content
}
nextTick(() => {
//
scrollDiv.value.setScrollTop(getMaxHeight())
})
if (chunk.isEnd){
answerList.value[answerList.value.length - 1].isEnd = true
answerList.value[answerList.value.length - 1].time = formatDate(new Date())
if (!chatList.value[answerList.value[answerList.value.length - 1].index].isStop){
chatList.value[chatList.value.length - 1].isEnd = true
chatList.value[chatList.value.length - 1].time = formatDate(new Date())
}
nextTick(() => {
//
scrollDiv.value.setScrollTop(getMaxHeight())
})
return Promise.resolve();
}
});
}
} catch (e) {
return Promise.reject(e);
}
return reader.read().then(write_stream);
};
return write_stream
};
const stopChat = (index) => {
chatList.value[index].isStop = true
}
const startChat = (index) => {
chatList.value[index].isStop = false
}
defineExpose({
setScrollBottom
})
</script>
<style lang="scss" scoped>
.ai-chat {
--padding-left: 40px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
position: relative;
color: #1f2329;
&__content {
height: auto;
padding-top: 0;
box-sizing: border-box;
.avatar {
float: left;
}
.content {
padding-left: var(--padding-left);
:deep(ol) {
margin-left: 16px !important;
}
}
.text {
padding: 6px 0;
}
.problem-button {
width: 100%;
border: none;
border-radius: 8px;
background: #f5f6f7;
height: 46px;
padding: 0 12px;
line-height: 46px;
box-sizing: border-box;
color: #1f2329;
-webkit-line-clamp: 1;
word-break: break-all;
&:hover {
background: var(--el-color-primary-light-9);
}
&.disabled {
&:hover {
background: #f5f6f7;
}
}
:deep(.el-icon) {
color: var(--el-color-primary);
}
}
}
&__operate {
background: #f3f7f9;
position: relative;
width: 100%;
box-sizing: border-box;
z-index: 10;
&:before {
background: linear-gradient(0deg, #f3f7f9 0%, rgba(243, 247, 249, 0) 100%);
content: '';
position: absolute;
width: 100%;
top: -16px;
left: 0;
height: 16px;
}
.operate-textarea {
box-shadow: 0px 6px 24px 0px rgba(31, 35, 41, 0.08);
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #ffffff;
box-sizing: border-box;
&:has(.el-textarea__inner:focus) {
border: 1px solid var(--el-color-primary);
scrollbar-width: none;
}
:deep(.el-textarea__inner) {
border-radius: 8px !important;
box-shadow: none;
resize: none;
padding: 12px 16px;
box-sizing: border-box;
scrollbar-width: none;
}
.operate {
padding: 6px 10px;
.el-icon {
font-size: 20px;
}
.sent-button {
max-height: none;
.el-icon {
font-size: 24px;
}
}
:deep(.el-loading-spinner) {
margin-top: -15px;
.circular {
width: 31px;
height: 31px;
}
}
}
}
}
.dialog-card {
border: none;
border-radius: 8px;
box-sizing: border-box;
}
}
.chat-width {
max-width: 80%;
margin: 0 auto;
}
.machineDiv {
cursor: pointer;
padding-top: 3px;
padding-bottom: 3px;
}
.machineDiv:hover {
background-color: #ebf1ff;
font-weight: bold;
}
@media only screen and (max-width: 1000px) {
.chat-width {
max-width: 100% !important;
margin: 0 auto;
}
}
</style>

611
vue-fastapi-frontend/src/views/aichat/antv-g6.vue

@ -0,0 +1,611 @@
<template>
<div :id="g6Index" style="width: 100%;height: 100%; overflow: hidden"></div>
</template>
<script setup>
import G6 from '@antv/g6';
import {watch} from "vue";
const props = defineProps({
is_large: Boolean,
g6Data: {
type: Array,
default: () => {}
},
g6Index: String
})
function initG6(){
const {
Util,
registerBehavior,
registerEdge,
registerNode
} = G6;
const isInBBox = (point, bbox) => {
const {
x,
y
} = point;
const {
minX,
minY,
maxX,
maxY
} = bbox;
return x < maxX && x > minX && y > minY && y < maxY;
};
const itemHeight = 20;
registerBehavior("dice-er-scroll", {
getDefaultCfg() {
return {
multiple: true,
};
},
getEvents() {
return {
itemHeight,
wheel: "scorll",
click: "click",
"node:mousemove": "move",
"node:mousedown": "mousedown",
"node:mouseup": "mouseup"
};
},
scorll(e) {
e.preventDefault();
const {
graph
} = this;
const nodes = graph.getNodes().filter((n) => {
const bbox = n.getBBox();
return isInBBox(graph.getPointByClient(e.clientX, e.clientY), bbox);
});
const x = e.deltaX || e.movementX;
let y = e.deltaY || e.movementY;
if (!y && navigator.userAgent.indexOf('Firefox') > -1) y = (-e.wheelDelta * 125) / 3
if (nodes) {
const edgesToUpdate = new Set();
nodes.forEach((node) => {
const model = node.getModel();
if (model.attrs.length < 2) {
return;
}
const idx = model.startIndex || 0;
let startX = model.startX || 0.5;
let startIndex = idx + y * 0.02;
startX -= x;
if (startIndex < 0) {
startIndex = 0;
}
if (startX > 0) {
startX = 0;
}
if (startIndex > model.attrs.length - 1) {
startIndex = model.attrs.length - 1;
}
graph.updateItem(node, {
startIndex,
startX,
});
node.getEdges().forEach(edge => edgesToUpdate.add(edge))
});
// G6 update the related edges when graph.updateItem with a node according to the new properties
// here you need to update the related edges manualy since the new properties { startIndex, startX } for the nodes are custom, and cannot be recognized by G6
edgesToUpdate.forEach(edge => edge.refresh())
}
},
click(e) {
const {
graph
} = this;
const item = e.item;
const shape = e.shape;
if (!item) {
return;
}
if (shape.get("name") === "collapse") {
graph.updateItem(item, {
collapsed: true,
size: [300, 50],
});
setTimeout(() => graph.layout(), 100);
} else if (shape.get("name") === "expand") {
graph.updateItem(item, {
collapsed: false,
size: [300, 80],
});
setTimeout(() => graph.layout(), 100);
}
},
mousedown(e) {
this.isMousedown = true;
},
mouseup(e) {
this.isMousedown = false;
},
move(e) {
if (this.isMousedown) return;
const name = e.shape.get("name");
const item = e.item;
if (name && name.startsWith("item")) {
this.graph.updateItem(item, {
selectedIndex: Number(name.split("-")[1]),
});
} else {
this.graph.updateItem(item, {
selectedIndex: NaN,
});
}
},
});
registerEdge("dice-er-edge", {
draw(cfg, group) {
const edge = group.cfg.item;
const sourceNode = edge.getSource().getModel();
const targetNode = edge.getTarget().getModel();
const sourceIndex = sourceNode.attrs.findIndex(
(e) => e.key === cfg.sourceKey
);
const sourceStartIndex = sourceNode.startIndex || 0;
let sourceY = 15;
if (!sourceNode.collapsed && sourceIndex > sourceStartIndex - 1) {
sourceY = 30 + (sourceIndex - sourceStartIndex + 0.5) * itemHeight;
sourceY = Math.min(sourceY, 80);
}
const targetIndex = targetNode.attrs.findIndex(
(e) => e.key === cfg.targetKey
);
const targetStartIndex = targetNode.startIndex || 0;
let targetY = 15;
if (!targetNode.collapsed && targetIndex > targetStartIndex - 1) {
targetY = (targetIndex - targetStartIndex + 0.5) * itemHeight + 30;
targetY = Math.min(targetY, 80);
}
const startPoint = {
...cfg.startPoint
};
const endPoint = {
...cfg.endPoint
};
startPoint.y = startPoint.y + sourceY;
endPoint.y = endPoint.y + targetY;
let shape;
if (sourceNode.id !== targetNode.id) {
shape = group.addShape("path", {
attrs: {
stroke: "#5B8FF9",
path: [
["M", startPoint.x, startPoint.y],
[
"C",
endPoint.x / 3 + (2 / 3) * startPoint.x,
startPoint.y,
endPoint.x / 3 + (2 / 3) * startPoint.x,
endPoint.y,
endPoint.x,
endPoint.y,
],
],
endArrow: true,
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: "path-shape",
});
} else if (!sourceNode.collapsed) {
let gap = Math.abs((startPoint.y - endPoint.y) / 3);
if (startPoint["index"] === 1) {
gap = -gap;
}
shape = group.addShape("path", {
attrs: {
stroke: "#5B8FF9",
path: [
["M", startPoint.x, startPoint.y],
[
"C",
startPoint.x - gap,
startPoint.y,
startPoint.x - gap,
endPoint.y,
startPoint.x,
endPoint.y,
],
],
endArrow: true,
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: "path-shape",
});
}
return shape;
},
afterDraw(cfg, group) {
const labelCfg = cfg.labelCfg || {};
const edge = group.cfg.item;
const sourceNode = edge.getSource().getModel();
const targetNode = edge.getTarget().getModel();
if (sourceNode.collapsed && targetNode.collapsed) {
return;
}
const path = group.find(
(element) => element.get("name") === "path-shape"
);
const labelStyle = Util.getLabelPosition(path, 0.5, 0, 0, true);
const label = group.addShape("text", {
attrs: {
...labelStyle,
text: cfg.label || '',
fill: "#000",
textAlign: "center",
stroke: "#fff",
lineWidth: 1,
},
});
label.rotateAtStart(labelStyle.rotate);
},
});
registerNode("dice-er-box", {
draw(cfg, group) {
const width = 250;
const height = 96;
const itemCount = 10;
const boxStyle = {
stroke: "#096DD9",
radius: 4,
};
const {
attrs = [],
startIndex = 0,
selectedIndex,
collapsed,
icon,
} = cfg;
const list = attrs;
const afterList = list.slice(
Math.floor(startIndex),
Math.floor(startIndex + itemCount - 1)
);
const offsetY = (0.5 - (startIndex % 1)) * itemHeight + 30;
group.addShape("rect", {
attrs: {
fill: boxStyle.stroke,
height: 30,
width,
radius: [boxStyle.radius, boxStyle.radius, 0, 0],
},
draggable: true,
});
let fontLeft = 12;
if (icon && icon.show !== false) {
group.addShape("image", {
attrs: {
x: 8,
y: 8,
height: 16,
width: 16,
...icon,
},
});
fontLeft += 18;
}
group.addShape("text", {
attrs: {
y: 22,
x: fontLeft,
fill: "#fff",
text: cfg.label,
fontSize: 12,
fontWeight: 500,
},
});
group.addShape("rect", {
attrs: {
x: 0,
y: collapsed ? 30 : 80,
height: 15,
width,
fill: "#eee",
radius: [0, 0, boxStyle.radius, boxStyle.radius],
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: collapsed ? "expand" : "collapse",
});
group.addShape("text", {
attrs: {
x: width / 2 - 6,
y: (collapsed ? 30 : 80) + 12,
text: collapsed ? "+" : "-",
width,
fill: "#000",
radius: [0, 0, boxStyle.radius, boxStyle.radius],
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: collapsed ? "expand" : "collapse",
});
const keyshape = group.addShape("rect", {
attrs: {
x: 0,
y: 0,
width,
height: collapsed ? 45 : height,
...boxStyle,
},
draggable: true,
});
if (collapsed) {
return keyshape;
}
const listContainer = group.addGroup({});
listContainer.setClip({
type: "rect",
attrs: {
x: -8,
y: 30,
width: width + 16,
height: 80 - 30,
},
});
listContainer.addShape({
type: "rect",
attrs: {
x: 1,
y: 30,
width: width - 2,
height: 80 - 30,
fill: "#fff",
},
draggable: true,
});
if (list.length > itemCount) {
const barStyle = {
width: 4,
padding: 0,
boxStyle: {
stroke: "#00000022",
},
innerStyle: {
fill: "#00000022",
},
};
listContainer.addShape("rect", {
attrs: {
y: 30,
x: width - barStyle.padding - barStyle.width,
width: barStyle.width,
height: height - 30,
...barStyle.boxStyle,
},
});
const indexHeight =
afterList.length > itemCount ?
(afterList.length / list.length) * height :
10;
listContainer.addShape("rect", {
attrs: {
y: 30 +
barStyle.padding +
(startIndex / list.length) * (height - 30),
x: width - barStyle.padding - barStyle.width,
width: barStyle.width,
height: Math.min(height, indexHeight),
...barStyle.innerStyle,
},
});
}
if (afterList) {
afterList.forEach((e, i) => {
const isSelected =
Math.floor(startIndex) + i === Number(selectedIndex);
let {
key = "", type
} = e;
if (type) {
key += " - " + type;
}
const label = key.length > 26 ? key.slice(0, 24) + "..." : key;
listContainer.addShape("rect", {
attrs: {
x: 1,
y: i * itemHeight - itemHeight / 2 + offsetY,
width: width - 4,
height: itemHeight,
radius: 2,
lineWidth: 1,
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: `item-${Math.floor(startIndex) + i}-content`,
draggable: true,
});
if (!cfg.hideDot) {
listContainer.addShape("circle", {
attrs: {
x: 0,
y: i * itemHeight + offsetY,
r: 3,
stroke: boxStyle.stroke,
fill: "white",
radius: 2,
lineWidth: 1,
cursor: "pointer",
},
});
listContainer.addShape("circle", {
attrs: {
x: width,
y: i * itemHeight + offsetY,
r: 3,
stroke: boxStyle.stroke,
fill: "white",
radius: 2,
lineWidth: 1,
cursor: "pointer",
},
});
}
listContainer.addShape("text", {
attrs: {
x: 12,
y: i * itemHeight + offsetY + 6,
text: label,
fontSize: 12,
fill: "#000",
fontFamily: "Avenir,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol",
full: e,
fontWeight: isSelected ? 500 : 100,
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: `item-${Math.floor(startIndex) + i}`,
});
});
}
return keyshape;
},
getAnchorPoints() {
return [
[0, 0],
[1, 0],
];
},
});
const dataTransform = (data) => {
const nodes = [];
const edges = [];
data.map((node) => {
nodes.push({
...node
});
if (node.attrs) {
node.attrs.forEach((attr) => {
if (attr.relation) {
attr.relation.forEach((relation) => {
edges.push({
source: node.id,
target: relation.nodeId,
sourceKey: attr.key,
targetKey: relation.key,
label: relation.label,
});
});
}
});
}
});
return {
nodes,
edges,
};
}
const container = document.getElementById(props.g6Index);
const width = props.is_fullscreen?document.documentElement.scrollWidth:container.scrollWidth;
const height = props.is_fullscreen?document.documentElement.scrollHeight:((container.scrollHeight || 500) - 20);
const graph = new G6.Graph({
container: container,
width,
height,
defaultNode: {
size: [300, 200],
type: 'dice-er-box',
color: '#5B8FF9',
style: {
fill: '#9EC9FF',
lineWidth: 3,
},
labelCfg: {
style: {
fill: 'black',
fontSize: 20,
},
},
},
defaultEdge: {
type: 'dice-er-edge',
style: {
stroke: '#e2e2e2',
lineWidth: 4,
endArrow: true,
},
},
modes: {
default: ['dice-er-scroll', 'drag-node', 'drag-canvas'],
},
layout: {
type: 'dagre',
rankdir: 'LR',
align: 'UL',
controlPoints: true,
nodesepFunc: () => 0.2,
ranksepFunc: () => 0.5,
},
animate: true,
fitView: true
})
graph.data(dataTransform(props.g6Data))
graph.render();
}
onMounted(() => {
initG6(false)
});
watch(() => props.is_large,
(value) =>{
let g6 = document.getElementById(props.g6Index)
g6.innerHTML=''
initG6(false)
}
)
</script>
<style scoped lang="scss">
</style>

39
vue-fastapi-frontend/src/views/aichat/auto-tooltip.vue

@ -0,0 +1,39 @@
<template>
<el-tooltip
v-bind="$attrs"
:disabled="!(containerWeight > contentWeight)"
effect="dark"
placement="bottom"
popper-class="auto-tooltip-popper"
>
<div ref="tagLabel" :class="['auto-tooltip', className]" :style="style">
<slot></slot>
</div>
</el-tooltip>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
defineOptions({ name: 'AutoTooltip' })
const props = defineProps({ className: String, style: Object })
const tagLabel = ref()
const containerWeight = ref(0)
const contentWeight = ref(0)
onMounted(() => {
nextTick(() => {
containerWeight.value = tagLabel.value?.scrollWidth
contentWeight.value = tagLabel.value?.clientWidth
})
window.addEventListener('resize', function () {
containerWeight.value = tagLabel.value?.scrollWidth
contentWeight.value = tagLabel.value?.clientWidth
})
})
</script>
<style lang="scss" scoped>
.auto-tooltip {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

42
vue-fastapi-frontend/src/views/aichat/chatTable.vue

@ -0,0 +1,42 @@
<template>
<div style="width: 100%;height: 100%">
<el-table :data="tableData" style="width: 100%; margin-top: 5px">
<el-table-column v-for="(value,key) in tableData[0]" :prop="key" :label="key"></el-table-column>
</el-table>
<div style="width: 100%; height: 32px">
<el-pagination
style="position: absolute;right: 0"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
:current-page="currentPage"
:page-sizes="[10, 20, 30, 40]"
:page-size="pageSize"
layout="total, prev, pager, next"
:total="data.length">
</el-pagination>
</div>
</div>
</template>
<script setup>
import {computed, ref} from "vue";
const props = defineProps({
data: Array
})
const currentPage = ref(1)
const pageSize = ref(10)
function handleCurrentChange(page) {
currentPage.value = page;
}
function handleSizeChange(size) {
pageSize.value = size;
}
const tableData = computed(() =>{
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return props.data.slice(start, end);
})
</script>
<style scoped lang="scss">
</style>

69
vue-fastapi-frontend/src/views/aichat/common-list.vue

@ -0,0 +1,69 @@
<template>
<div class="common-list">
<ul v-if="data.length > 0" style="list-style: none;margin: 0;padding: 0">
<template v-for="(item, index) in data" :key="index">
<li
@click.prevent="clickHandle(item, index)"
class="cursor"
:class="mouseId === item.sessionId ? 'active' : ''"
@mouseenter="mouseenter(item)"
@mouseleave="mouseleave()"
>
<slot :row="item" :index="index"> </slot>
</li>
</template>
</ul>
<slot name="empty" v-else>
<el-empty description="暂无数据" />
</slot>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import {getChatList} from "@/api/aichat/aichat.js";
defineOptions({ name: 'CommonList' })
const props = defineProps({
mouseId: String,
data: {
type: Array,
default: []
},
defaultActive: ''
})
const emit = defineEmits(['click', 'mouseenter', 'mouseleave'])
function mouseenter(row) {
emit('mouseenter', row)
}
function mouseleave() {
emit('mouseleave')
}
function clickHandle(row, index) {
emit('click', row)
}
</script>
<style lang="scss" scoped>
/* 通用 ui li样式 */
.common-list {
li {
padding: 10px 16px;
font-weight: 400;
&.active {
background: var(--el-color-primary-light-9);
border-radius: 4px;
color: var(--el-color-primary);
font-weight: 500;
}
}
}
.cursor {
cursor: pointer;
padding: 6px 16px;
}
</style>

604
vue-fastapi-frontend/src/views/aichat/fullscreenG6.vue

@ -0,0 +1,604 @@
<template>
<div id="fullscreenG6" style="width: 100%;height: 100%; overflow: hidden"></div>
</template>
<script setup>
import G6 from '@antv/g6';
import {watch} from "vue";
const props = defineProps({
g6Data: {
type: Array,
default: () => {}
}
})
function initG6(){
const {
Util,
registerBehavior,
registerEdge,
registerNode
} = G6;
const isInBBox = (point, bbox) => {
const {
x,
y
} = point;
const {
minX,
minY,
maxX,
maxY
} = bbox;
return x < maxX && x > minX && y > minY && y < maxY;
};
const itemHeight = 20;
registerBehavior("dice-er-scroll", {
getDefaultCfg() {
return {
multiple: true,
};
},
getEvents() {
return {
itemHeight,
wheel: "scorll",
click: "click",
"node:mousemove": "move",
"node:mousedown": "mousedown",
"node:mouseup": "mouseup"
};
},
scorll(e) {
e.preventDefault();
const {
graph
} = this;
const nodes = graph.getNodes().filter((n) => {
const bbox = n.getBBox();
return isInBBox(graph.getPointByClient(e.clientX, e.clientY), bbox);
});
const x = e.deltaX || e.movementX;
let y = e.deltaY || e.movementY;
if (!y && navigator.userAgent.indexOf('Firefox') > -1) y = (-e.wheelDelta * 125) / 3
if (nodes) {
const edgesToUpdate = new Set();
nodes.forEach((node) => {
const model = node.getModel();
if (model.attrs.length < 2) {
return;
}
const idx = model.startIndex || 0;
let startX = model.startX || 0.5;
let startIndex = idx + y * 0.02;
startX -= x;
if (startIndex < 0) {
startIndex = 0;
}
if (startX > 0) {
startX = 0;
}
if (startIndex > model.attrs.length - 1) {
startIndex = model.attrs.length - 1;
}
graph.updateItem(node, {
startIndex,
startX,
});
node.getEdges().forEach(edge => edgesToUpdate.add(edge))
});
// G6 update the related edges when graph.updateItem with a node according to the new properties
// here you need to update the related edges manualy since the new properties { startIndex, startX } for the nodes are custom, and cannot be recognized by G6
edgesToUpdate.forEach(edge => edge.refresh())
}
},
click(e) {
const {
graph
} = this;
const item = e.item;
const shape = e.shape;
if (!item) {
return;
}
if (shape.get("name") === "collapse") {
graph.updateItem(item, {
collapsed: true,
size: [300, 50],
});
setTimeout(() => graph.layout(), 100);
} else if (shape.get("name") === "expand") {
graph.updateItem(item, {
collapsed: false,
size: [300, 80],
});
setTimeout(() => graph.layout(), 100);
}
},
mousedown(e) {
this.isMousedown = true;
},
mouseup(e) {
this.isMousedown = false;
},
move(e) {
if (this.isMousedown) return;
const name = e.shape.get("name");
const item = e.item;
if (name && name.startsWith("item")) {
this.graph.updateItem(item, {
selectedIndex: Number(name.split("-")[1]),
});
} else {
this.graph.updateItem(item, {
selectedIndex: NaN,
});
}
},
});
registerEdge("dice-er-edge", {
draw(cfg, group) {
const edge = group.cfg.item;
const sourceNode = edge.getSource().getModel();
const targetNode = edge.getTarget().getModel();
const sourceIndex = sourceNode.attrs.findIndex(
(e) => e.key === cfg.sourceKey
);
const sourceStartIndex = sourceNode.startIndex || 0;
let sourceY = 15;
if (!sourceNode.collapsed && sourceIndex > sourceStartIndex - 1) {
sourceY = 30 + (sourceIndex - sourceStartIndex + 0.5) * itemHeight;
sourceY = Math.min(sourceY, 80);
}
const targetIndex = targetNode.attrs.findIndex(
(e) => e.key === cfg.targetKey
);
const targetStartIndex = targetNode.startIndex || 0;
let targetY = 15;
if (!targetNode.collapsed && targetIndex > targetStartIndex - 1) {
targetY = (targetIndex - targetStartIndex + 0.5) * itemHeight + 30;
targetY = Math.min(targetY, 80);
}
const startPoint = {
...cfg.startPoint
};
const endPoint = {
...cfg.endPoint
};
startPoint.y = startPoint.y + sourceY;
endPoint.y = endPoint.y + targetY;
let shape;
if (sourceNode.id !== targetNode.id) {
shape = group.addShape("path", {
attrs: {
stroke: "#5B8FF9",
path: [
["M", startPoint.x, startPoint.y],
[
"C",
endPoint.x / 3 + (2 / 3) * startPoint.x,
startPoint.y,
endPoint.x / 3 + (2 / 3) * startPoint.x,
endPoint.y,
endPoint.x,
endPoint.y,
],
],
endArrow: true,
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: "path-shape",
});
} else if (!sourceNode.collapsed) {
let gap = Math.abs((startPoint.y - endPoint.y) / 3);
if (startPoint["index"] === 1) {
gap = -gap;
}
shape = group.addShape("path", {
attrs: {
stroke: "#5B8FF9",
path: [
["M", startPoint.x, startPoint.y],
[
"C",
startPoint.x - gap,
startPoint.y,
startPoint.x - gap,
endPoint.y,
startPoint.x,
endPoint.y,
],
],
endArrow: true,
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: "path-shape",
});
}
return shape;
},
afterDraw(cfg, group) {
const labelCfg = cfg.labelCfg || {};
const edge = group.cfg.item;
const sourceNode = edge.getSource().getModel();
const targetNode = edge.getTarget().getModel();
if (sourceNode.collapsed && targetNode.collapsed) {
return;
}
const path = group.find(
(element) => element.get("name") === "path-shape"
);
const labelStyle = Util.getLabelPosition(path, 0.5, 0, 0, true);
const label = group.addShape("text", {
attrs: {
...labelStyle,
text: cfg.label || '',
fill: "#000",
textAlign: "center",
stroke: "#fff",
lineWidth: 1,
},
});
label.rotateAtStart(labelStyle.rotate);
},
});
registerNode("dice-er-box", {
draw(cfg, group) {
const width = 250;
const height = 96;
const itemCount = 10;
const boxStyle = {
stroke: "#096DD9",
radius: 4,
};
const {
attrs = [],
startIndex = 0,
selectedIndex,
collapsed,
icon,
} = cfg;
const list = attrs;
const afterList = list.slice(
Math.floor(startIndex),
Math.floor(startIndex + itemCount - 1)
);
const offsetY = (0.5 - (startIndex % 1)) * itemHeight + 30;
group.addShape("rect", {
attrs: {
fill: boxStyle.stroke,
height: 30,
width,
radius: [boxStyle.radius, boxStyle.radius, 0, 0],
},
draggable: true,
});
let fontLeft = 12;
if (icon && icon.show !== false) {
group.addShape("image", {
attrs: {
x: 8,
y: 8,
height: 16,
width: 16,
...icon,
},
});
fontLeft += 18;
}
group.addShape("text", {
attrs: {
y: 22,
x: fontLeft,
fill: "#fff",
text: cfg.label,
fontSize: 12,
fontWeight: 500,
},
});
group.addShape("rect", {
attrs: {
x: 0,
y: collapsed ? 30 : 80,
height: 15,
width,
fill: "#eee",
radius: [0, 0, boxStyle.radius, boxStyle.radius],
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: collapsed ? "expand" : "collapse",
});
group.addShape("text", {
attrs: {
x: width / 2 - 6,
y: (collapsed ? 30 : 80) + 12,
text: collapsed ? "+" : "-",
width,
fill: "#000",
radius: [0, 0, boxStyle.radius, boxStyle.radius],
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: collapsed ? "expand" : "collapse",
});
const keyshape = group.addShape("rect", {
attrs: {
x: 0,
y: 0,
width,
height: collapsed ? 45 : height,
...boxStyle,
},
draggable: true,
});
if (collapsed) {
return keyshape;
}
const listContainer = group.addGroup({});
listContainer.setClip({
type: "rect",
attrs: {
x: -8,
y: 30,
width: width + 16,
height: 80 - 30,
},
});
listContainer.addShape({
type: "rect",
attrs: {
x: 1,
y: 30,
width: width - 2,
height: 80 - 30,
fill: "#fff",
},
draggable: true,
});
if (list.length > itemCount) {
const barStyle = {
width: 4,
padding: 0,
boxStyle: {
stroke: "#00000022",
},
innerStyle: {
fill: "#00000022",
},
};
listContainer.addShape("rect", {
attrs: {
y: 30,
x: width - barStyle.padding - barStyle.width,
width: barStyle.width,
height: height - 30,
...barStyle.boxStyle,
},
});
const indexHeight =
afterList.length > itemCount ?
(afterList.length / list.length) * height :
10;
listContainer.addShape("rect", {
attrs: {
y: 30 +
barStyle.padding +
(startIndex / list.length) * (height - 30),
x: width - barStyle.padding - barStyle.width,
width: barStyle.width,
height: Math.min(height, indexHeight),
...barStyle.innerStyle,
},
});
}
if (afterList) {
afterList.forEach((e, i) => {
const isSelected =
Math.floor(startIndex) + i === Number(selectedIndex);
let {
key = "", type
} = e;
if (type) {
key += " - " + type;
}
const label = key.length > 26 ? key.slice(0, 24) + "..." : key;
listContainer.addShape("rect", {
attrs: {
x: 1,
y: i * itemHeight - itemHeight / 2 + offsetY,
width: width - 4,
height: itemHeight,
radius: 2,
lineWidth: 1,
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: `item-${Math.floor(startIndex) + i}-content`,
draggable: true,
});
if (!cfg.hideDot) {
listContainer.addShape("circle", {
attrs: {
x: 0,
y: i * itemHeight + offsetY,
r: 3,
stroke: boxStyle.stroke,
fill: "white",
radius: 2,
lineWidth: 1,
cursor: "pointer",
},
});
listContainer.addShape("circle", {
attrs: {
x: width,
y: i * itemHeight + offsetY,
r: 3,
stroke: boxStyle.stroke,
fill: "white",
radius: 2,
lineWidth: 1,
cursor: "pointer",
},
});
}
listContainer.addShape("text", {
attrs: {
x: 12,
y: i * itemHeight + offsetY + 6,
text: label,
fontSize: 12,
fill: "#000",
fontFamily: "Avenir,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol",
full: e,
fontWeight: isSelected ? 500 : 100,
cursor: "pointer",
},
// must be assigned in G6 3.3 and later versions. it can be any string you want, but should be unique in a custom item type
name: `item-${Math.floor(startIndex) + i}`,
});
});
}
return keyshape;
},
getAnchorPoints() {
return [
[0, 0],
[1, 0],
];
},
});
const dataTransform = (data) => {
const nodes = [];
const edges = [];
data.map((node) => {
nodes.push({
...node
});
if (node.attrs) {
node.attrs.forEach((attr) => {
if (attr.relation) {
attr.relation.forEach((relation) => {
edges.push({
source: node.id,
target: relation.nodeId,
sourceKey: attr.key,
targetKey: relation.key,
label: relation.label,
});
});
}
});
}
});
return {
nodes,
edges,
};
}
const container = document.getElementById("fullscreenG6");
const width = document.documentElement.scrollWidth - 30;
const height = document.documentElement.scrollHeight - 30;
const graph = new G6.Graph({
container: container,
width,
height,
defaultNode: {
size: [300, 200],
type: 'dice-er-box',
color: '#5B8FF9',
style: {
fill: '#9EC9FF',
lineWidth: 3,
},
labelCfg: {
style: {
fill: 'black',
fontSize: 20,
},
},
},
defaultEdge: {
type: 'dice-er-edge',
style: {
stroke: '#e2e2e2',
lineWidth: 4,
endArrow: true,
},
},
modes: {
default: ['dice-er-scroll', 'drag-node', 'drag-canvas'],
},
layout: {
type: 'dagre',
rankdir: 'LR',
align: 'UL',
controlPoints: true,
nodesepFunc: () => 0.2,
ranksepFunc: () => 0.5,
},
animate: true,
fitView: true
})
graph.data(dataTransform(props.g6Data))
graph.render();
}
onMounted(() => {
initG6()
});
</script>
<style scoped lang="scss">
</style>

21
vue-fastapi-frontend/src/views/aichat/htmlCharts.vue

@ -0,0 +1,21 @@
<template>
<iframe :id="index" :srcDoc="data" style="width: 100%;height: 100%;border: none"></iframe>
</template>
<script setup>
import {watch} from "vue";
const props = defineProps({
is_large: Boolean,
data: String,
index: String,
})
watch(() => props.is_large,
(value) =>{
let iframe = document.getElementById(props.index)
iframe.contentWindow.location.reload()
}
)
</script>
<style scoped lang="scss">
</style>

259
vue-fastapi-frontend/src/views/aichat/index.vue

@ -0,0 +1,259 @@
<template>
<div class="chat-embed layout-bg">
<div class="chat-embed__header">
<div class="chat-width flex align-center" style="height: 56px;align-items: center;line-height: 56px">
<div class="mr-12 ml-24 flex">
<el-avatar
shape="square"
:size="32"
style="background: none"
>
<img src="@/assets/logo/deepseek.png" alt="" />
</el-avatar>
</div>
<h4 style="color: #1f2329;font-size: 16px; font-style: normal; font-weight: bold;margin: 0; -webkit-font-smoothing: antialiased">果知小助手</h4>
</div>
</div>
<div class="chat-embed__main">
<AiChat
ref="AiChatRef"
:record="currentRecordList"
:is_large="is_large"
:cookieSessionId = "sessionId"
class="AiChat-embed"
@scroll="handleScroll"
></AiChat>
</div>
<el-link class="chat-popover-button" :underline="false" @click.prevent.stop="showChatHistory">
<i class="ri-history-line"></i>
</el-link>
<el-link style="z-index: 100; position: absolute; top: 18px; right: 120px; font-size: 20px;" :underline="false" @click.prevent.stop="newChat" :disabled="currentRecordList.length === 0">
<el-icon><Plus /></el-icon>
</el-link>
<el-collapse-transition>
<div v-show="show" class="chat-popover w-full" style="width: 100%;" v-click-outside="clickoutside">
<div class="border-b p-16-24" style="border-bottom: 1px solid #dee0e3; padding: 16px 24px; font-size: 14px">
<span>历史记录</span>
</div>
<el-scrollbar max-height="300">
<div class="p-8" style="padding: 8px">
<common-list
:data="chatLogeData"
:mouseId="mouseId"
:defaultActive="sessionId"
@click="clickListHandle"
@mouseenter="mouseenter"
@mouseleave="mouseId = ''"
>
<template #default="{ row }">
<div class="flex-between" style="display: flex; justify-content: space-between; align-items: center;">
<auto-tooltip :content="row.sessionName">
{{ row.sessionName }}
</auto-tooltip>
<div @click.stop v-if="mouseId === row.sessionId">
<el-button style="padding: 0" link @click.stop="deleteLog(row)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</template>
<template #empty>
<div class="text-center">
<el-text type="info">暂无历史记录</el-text>
</div>
</template>
</common-list>
</div>
<div v-if="chatLogeData.length" class="gradient-divider lighter mt-8" style="font-weight: 400; margin-top: 8px">
<span>仅显示最近 20 条对话</span>
</div>
</el-scrollbar>
</div>
</el-collapse-transition>
<div class="chat-popover-mask" v-show="show"></div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive, nextTick, computed } from 'vue'
import { v4 as uuidv4 } from 'uuid';
import Cookies from 'js-cookie'
import CommonList from './common-list.vue'
import AiChat from './aichat.vue'
import AutoTooltip from './auto-tooltip.vue'
import { useRoute } from 'vue-router'
import { listChatHistory, getChatList, DeleteChatSession } from "@/api/aichat/aichat";
const route = useRoute()
const { proxy } = getCurrentInstance();
const {
params: { accessToken }
} = route
const props = defineProps({
is_large: Boolean,
chatDataList: Array,
})
const AiChatRef = ref()
const chatLogeData = ref([])
const show = ref(false)
const currentRecordList = ref([])
const sessionId = ref(Cookies.get("chatSessionId")) // Id 'new'
const mouseId = ref('')
function mouseenter(row) {
mouseId.value = row.sessionId
}
function deleteLog(row) {
let sessionId = row.sessionId
DeleteChatSession(sessionId).then(res=>{
listChatHistory(sessionId).then(response =>{
chatLogeData.value = response.data
})
proxy.$modal.msgSuccess("复制成功");
})
}
function clickoutside() {
show.value = false
}
function showChatHistory(){
show.value = true
listChatHistory(sessionId.value).then(response =>{
chatLogeData.value = response.data
})
}
function newChat() {
currentRecordList.value = []
sessionId.value = uuidv4()
Cookies.set("chatSessionId",sessionId.value)
}
function handleScroll(event) {
if (event.scrollTop === 0 && currentRecordList.value.length>0) {
const history_height = event.dialogScrollbar.offsetHeight
event.scrollDiv.setScrollTop(event.dialogScrollbar.offsetHeight - history_height)
}
}
function clickListHandle(item){
getChatList(item.sessionId).then(res=>{
currentRecordList.value = []
let array = res.data
for (let i = 0; i < array.length; i++) {
if (array[i].type === 'answer'){
array[i].content = JSON.parse(array[i].content)
}
if (array[i].type === 'question'){
array[i].file = JSON.parse(array[i].file)
}
currentRecordList.value.push(array[i])
AiChatRef.value.setScrollBottom()
}
show.value = false
sessionId.value = item.sessionId
Cookies.set("chatSessionId",sessionId.value)
})
}
watch(() => props.chatDataList, value => currentRecordList.value = JSON.parse(JSON.stringify(value)))
</script>
<style lang="scss">
.chat-embed {
overflow: hidden;
height: 100%;
&__header {
background: linear-gradient(90deg, #ebf1ff 24.34%, #e5fbf8 56.18%, #f2ebfe 90.18%);;
position: absolute;
width: 100%;
left: 0;
top: 0;
z-index: 100;
height: 56px;
line-height: 56px;
box-sizing: border-box;
border-bottom: 1px solid #dee0e3;
}
&__main {
padding-top: 80px;
height: 100%;
overflow: hidden;
}
.new-chat-button {
z-index: 11;
}
//
.chat-popover {
position: absolute;
top: 56px;
background: #ffffff;
padding-bottom: 24px;
z-index: 2009;
}
.chat-popover-button {
z-index: 100;
position: absolute;
top: 18px;
right: 85px;
font-size: 20px;
}
.chat-popover-mask {
background-color: var(--el-overlay-color-lighter);
bottom: 0;
height: 100%;
left: 0;
overflow: auto;
position: absolute;
right: 0;
top: 56px;
z-index: 2008;
}
.gradient-divider {
position: relative;
text-align: center;
color: var(--el-color-info);
::before {
content: '';
width: 17%;
height: 1px;
background: linear-gradient(90deg, rgba(222, 224, 227, 0) 0%, #dee0e3 100%);
position: absolute;
left: 16px;
top: 50%;
}
::after {
content: '';
width: 17%;
height: 1px;
background: linear-gradient(90deg, #dee0e3 0%, rgba(222, 224, 227, 0) 100%);
position: absolute;
right: 16px;
top: 50%;
}
}
.AiChat-embed {
.ai-chat__operate {
padding-top: 12px;
}
}
.chat-width {
max-width: 860px;
margin: 0 auto;
}
}
.flex {
display: flex;
}
.mr-12 {
margin-right: 12px;
}
.ml-24 {
margin-left: 24px;
}
</style>

23
vue-fastapi-frontend/src/views/aichat/markdown.vue

@ -0,0 +1,23 @@
<template>
<div class="markdown-content" v-html="compiledMarkdown" style="font-size: 14px"></div>
</template>
<script setup>
import { ref, watch} from 'vue';
import MarkdownIt from 'markdown-it';
const props = defineProps({
markdownString: String
})
const compiledMarkdown = ref('')
const md = new MarkdownIt()
watch(() => props.markdownString, (newValue) => {
if (newValue){
compiledMarkdown.value = md.render(newValue.replace("\n","\n\n"));
}
}, { immediate: true });
</script>
<style lang="scss">
.markdown-content a {
text-decoration: underline;
color: blue
}
</style>

5
vue-fastapi-frontend/src/views/login.vue

@ -68,7 +68,8 @@
import { getCodeImg } from "@/api/login"; import { getCodeImg } from "@/api/login";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { encrypt, decrypt } from "@/utils/jsencrypt"; import { encrypt, decrypt } from "@/utils/jsencrypt";
import useUserStore from '@/store/modules/user' import useUserStore from '@/store/modules/user';
import md5 from 'js-md5';
const userStore = useUserStore() const userStore = useUserStore()
const route = useRoute(); const route = useRoute();
@ -108,7 +109,7 @@ function handleLogin() {
// cookie // cookie
if (loginForm.value.rememberMe) { if (loginForm.value.rememberMe) {
Cookies.set("username", loginForm.value.username, { expires: 30 }); Cookies.set("username", loginForm.value.username, { expires: 30 });
Cookies.set("password", encrypt(loginForm.value.password), { expires: 30 }); Cookies.set("password", md5(loginForm.value.password), { expires: 30 });
Cookies.set("rememberMe", loginForm.value.rememberMe, { expires: 30 }); Cookies.set("rememberMe", loginForm.value.rememberMe, { expires: 30 });
} else { } else {
// //

3
vue-fastapi-frontend/src/views/system/user/profile/resetPwd.vue

@ -18,6 +18,7 @@
<script setup> <script setup>
import { updateUserPwd } from "@/api/system/user"; import { updateUserPwd } from "@/api/system/user";
import md5 from 'js-md5'
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
@ -44,7 +45,7 @@ const rules = ref({
function submit() { function submit() {
proxy.$refs.pwdRef.validate(valid => { proxy.$refs.pwdRef.validate(valid => {
if (valid) { if (valid) {
updateUserPwd(user.oldPassword, user.newPassword).then(response => { updateUserPwd(md5(user.oldPassword), md5(user.newPassword)).then(response => {
proxy.$modal.msgSuccess("修改成功"); proxy.$modal.msgSuccess("修改成功");
}); });
} }

5
vue-fastapi-frontend/vite.config.js

@ -34,6 +34,11 @@ export default defineConfig(({ mode, command }) => {
target: 'http://127.0.0.1:9100', target: 'http://127.0.0.1:9100',
changeOrigin: true, changeOrigin: true,
rewrite: (p) => p.replace(/^\/dev-api/, '') rewrite: (p) => p.replace(/^\/dev-api/, '')
},
'/aichat-api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/aichat-api/, '')
} }
} }
}, },

Loading…
Cancel
Save