@ -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> |
@ -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> |
@ -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> |
@ -0,0 +1,6 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<project version="4"> |
|||
<component name="VcsDirectoryMappings"> |
|||
<mapping directory="" vcs="Git" /> |
|||
</component> |
|||
</project> |
@ -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> |
@ -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)) |
@ -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)]) |
@ -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='问答时间') |
|||
|
@ -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]] |
@ -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='操作成功') |
@ -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) |
|||
|
@ -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' |
|||
}) |
|||
} |
After Width: | Height: | Size: 945 B |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 6.5 KiB |
After Width: | Height: | Size: 68 KiB |
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 22 KiB |
@ -1,9 +1,11 @@ |
|||
import hasRole from './permission/hasRole' |
|||
import hasPermi from './permission/hasPermi' |
|||
import copyText from './common/copyText' |
|||
import { ClickOutside as vClickOutside } from 'element-plus' |
|||
|
|||
export default function directive(app){ |
|||
app.directive('hasRole', hasRole) |
|||
app.directive('hasPermi', hasPermi) |
|||
app.directive('copyText', copyText) |
|||
app.directive('click-outside', vClickOutside) |
|||
} |
@ -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 '即将到期' |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
@ -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> |
@ -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 |
|||
// 移除图片  |
|||
.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> |
@ -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){ |
|||
//说明是html的echarts图 |
|||
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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |