第一次提交
This commit is contained in:
140
jeecgboot-vue3/src/views/super/airag/aiapp/AiApp.api.ts
Normal file
140
jeecgboot-vue3/src/views/super/airag/aiapp/AiApp.api.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
export enum Api {
|
||||
//知识库管理
|
||||
list = '/airag/app/list',
|
||||
save = '/airag/app/edit',
|
||||
release = '/airag/app/release',
|
||||
delete = '/airag/app/delete',
|
||||
queryById = '/airag/app/queryById',
|
||||
queryBathById = '/airag/knowledge/query/batch/byId',
|
||||
queryKnowledgeById = '/airag/knowledge/queryById',
|
||||
queryFlowById = '/airag/flow/queryById',
|
||||
queryFlowByIds = '/airag/flow/list',
|
||||
promptGenerate = '/airag/app/prompt/generate',
|
||||
generateMemoryByAppId = '/airag/app/prompt/generateMemoryByAppId',
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询应用
|
||||
* @param params
|
||||
*/
|
||||
export const appList = (params) => {
|
||||
return defHttp.get({ url: Api.list, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询知识库
|
||||
* @param params
|
||||
*/
|
||||
export const queryKnowledgeBathById = (params) => {
|
||||
return defHttp.get({ url: Api.queryBathById, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询知识库(单条)
|
||||
* @param params
|
||||
*/
|
||||
export const queryKnowledgeById = (params) => {
|
||||
return defHttp.get({ url: Api.queryKnowledgeById, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据应用id查询应用
|
||||
* @param params
|
||||
*/
|
||||
export const queryById = (params) => {
|
||||
return defHttp.get({ url: Api.queryById, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增应用
|
||||
* @param params
|
||||
*/
|
||||
export const saveApp = (params) => {
|
||||
return defHttp.put({ url: Api.save, params });
|
||||
};
|
||||
|
||||
// 发布应用
|
||||
export function releaseApp(appId: string, release = false) {
|
||||
return defHttp.post({
|
||||
url: Api.release,
|
||||
params: {
|
||||
id: appId,
|
||||
release: release,
|
||||
}
|
||||
}, {joinParamsToUrl: true});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除应用
|
||||
* @param params
|
||||
* @param handleSuccess
|
||||
*/
|
||||
export const deleteApp = (params, handleSuccess) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '是否删除名称为'+params.name+'的应用吗?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
return defHttp.delete({ url: Api.delete, params }, { joinParamsToUrl: true }).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 根据应用id查询流程
|
||||
* @param params
|
||||
*/
|
||||
export const queryFlowById = (params) => {
|
||||
return defHttp.get({ url: Api.queryFlowById, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据应用id查询流程
|
||||
* @param params
|
||||
*/
|
||||
export const queryFlowByIds = (params) => {
|
||||
return defHttp.get({ url: Api.queryFlowByIds, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用编排
|
||||
* @param params
|
||||
*/
|
||||
export const promptGenerate = (params) => {
|
||||
return defHttp.post(
|
||||
{
|
||||
url: Api.promptGenerate+'?prompt='+ params.prompt,
|
||||
adapter: 'fetch',
|
||||
responseType: 'stream',
|
||||
timeout: 5 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
isTransformResponse: false,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用编排
|
||||
* @param params
|
||||
*/
|
||||
export const generateMemoryByAppId = (params) => {
|
||||
return defHttp.post(
|
||||
{
|
||||
url: Api.generateMemoryByAppId+'?variables='+ params.variables + '&memoryId='+ params.memoryId,
|
||||
adapter: 'fetch',
|
||||
responseType: 'stream',
|
||||
timeout: 5 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
isTransformResponse: false,
|
||||
}
|
||||
);
|
||||
};
|
||||
88
jeecgboot-vue3/src/views/super/airag/aiapp/AiApp.data.ts
Normal file
88
jeecgboot-vue3/src/views/super/airag/aiapp/AiApp.data.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { FormSchema } from '@/components/Form';
|
||||
|
||||
/**
|
||||
* 表单
|
||||
*/
|
||||
export const formSchema: FormSchema[] = [
|
||||
{
|
||||
label: 'id',
|
||||
field: 'id',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: '应用名称',
|
||||
field: 'name',
|
||||
required: true,
|
||||
componentProps: {
|
||||
//是否展示字数
|
||||
showCount: true,
|
||||
maxlength: 64,
|
||||
},
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
label: '应用描述',
|
||||
field: 'descr',
|
||||
component: 'InputTextArea',
|
||||
componentProps: {
|
||||
placeholder: '描述该应用的应用场景及用途',
|
||||
rows: 4,
|
||||
//是否展示字数
|
||||
showCount: true,
|
||||
maxlength: 256,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '应用图标',
|
||||
field: 'icon',
|
||||
component: 'JImageUpload',
|
||||
},
|
||||
{
|
||||
label: '选择应用类型',
|
||||
field: 'type',
|
||||
component: 'Input',
|
||||
show:({ values })=>{
|
||||
return !values.id;
|
||||
},
|
||||
slot: 'typeSlot',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 快捷指令表单
|
||||
*/
|
||||
export const quickCommandFormSchema: FormSchema[] = [
|
||||
{
|
||||
label: 'key',
|
||||
field: 'key',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: '按钮名称',
|
||||
field: 'name',
|
||||
required: true,
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
showCount: true,
|
||||
maxLength: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '按钮图标',
|
||||
field: 'icon',
|
||||
component: 'IconPicker',
|
||||
},
|
||||
{
|
||||
label: '指令内容',
|
||||
field: 'descr',
|
||||
required: true,
|
||||
component: 'InputTextArea',
|
||||
componentProps: {
|
||||
autosize: { minRows: 4, maxRows: 4 },
|
||||
showCount: true,
|
||||
maxLength: 100,
|
||||
}
|
||||
},
|
||||
];
|
||||
600
jeecgboot-vue3/src/views/super/airag/aiapp/AiAppList.vue
Normal file
600
jeecgboot-vue3/src/views/super/airag/aiapp/AiAppList.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<!--知识库文档列表-->
|
||||
<template>
|
||||
<div class="knowledge">
|
||||
<!--查询区域-->
|
||||
<div class="jeecg-basic-table-form-container">
|
||||
<a-form
|
||||
ref="formRef"
|
||||
@keyup.enter.native="searchQuery"
|
||||
:model="queryParam"
|
||||
:label-col="labelCol"
|
||||
:wrapper-col="wrapperCol"
|
||||
style="background-color: #f7f8fc"
|
||||
>
|
||||
<a-row :gutter="24">
|
||||
<a-col :xl="7" :lg="7" :md="8" :sm="24">
|
||||
<a-form-item name="name" label="应用名称">
|
||||
<JInput v-model:value="queryParam.name" placeholder="请输入应用名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xl="7" :lg="7" :md="8" :sm="24">
|
||||
<a-form-item name="type" label="应用类型">
|
||||
<j-dict-select-tag v-model:value="queryParam.type" dict-code="ai_app_type" placeholder="请选择应用类型" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xl="6" :lg="7" :md="8" :sm="24">
|
||||
<span style="float: left; overflow: hidden" class="table-page-search-submitButtons">
|
||||
<a-col :lg="6">
|
||||
<a-button type="primary" preIcon="ant-design:search-outlined" @click="searchQuery">查询</a-button>
|
||||
<a-button type="primary" preIcon="ant-design:reload-outlined" @click="searchReset" style="margin-left: 8px">重置</a-button>
|
||||
</a-col>
|
||||
</span>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
<a-row :span="24" class="knowledge-row">
|
||||
<a-col :xxl="4" :xl="6" :lg="6" :md="6" :sm="12" :xs="24">
|
||||
<a-card class="add-knowledge-card" @click="handleCreateApp">
|
||||
<div class="flex">
|
||||
<Icon icon="ant-design:plus-outlined" class="add-knowledge-card-icon" size="20"></Icon>
|
||||
<span class="add-knowledge-card-title">创建应用</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xxl="4" :xl="6" :lg="6" :md="6" :sm="12" :xs="24" v-for="item in knowledgeAppDataList">
|
||||
<a-card class="knowledge-card pointer" @click="handleEditClick(item)">
|
||||
<div class="flex">
|
||||
<img class="header-img" :src="getImage(item.icon)" />
|
||||
<div class="header-text">
|
||||
<span class="header-text-top header-name ellipsis"> {{ item.name }} </span>
|
||||
<span class="header-text-top header-create ellipsis">
|
||||
<a-tag v-if="item.status === 'release'" color="green">已发布</a-tag>
|
||||
<a-tag v-if="item.status === 'disable'">已禁用</a-tag>
|
||||
<span>创建者:{{ item.createBy_dictText || item.createBy }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-tag">
|
||||
<a-tag color="#EBF1FF" style="margin-right: 0" v-if="item.type === 'chatSimple'">
|
||||
<span style="color: #3370ff">智能体</span>
|
||||
</a-tag>
|
||||
<a-tag color="#FDF6EC" style="margin-right: 0" v-if="item.type === 'chatFLow'">
|
||||
<span style="color: #e6a343">高级编排</span>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="card-description">
|
||||
<span>{{ item.descr || '暂无描述' }}</span>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a-tooltip title="演示">
|
||||
<div class="card-footer-icon" @click.prevent.stop="handleViewClick(item.id)">
|
||||
<Icon class="operation" icon="ant-design:youtube-outlined" size="18"
|
||||
color="#1F2329"></Icon>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<template v-if="item.status !== 'release'">
|
||||
<a-divider type="vertical" style="float: left" />
|
||||
<a-tooltip title="删除">
|
||||
<div class="card-footer-icon" @click.prevent.stop="handleDeleteClick(item)">
|
||||
<Icon icon="ant-design:delete-outlined" class="operation" size="16"
|
||||
color="#1F2329"></Icon>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-divider type="vertical" style="float: left" />
|
||||
<a-tooltip title="发布">
|
||||
<a-dropdown class="card-footer-icon" placement="bottomRight" :trigger="['click']">
|
||||
<div @click.prevent.stop>
|
||||
<Icon style="position: relative;top: 1px" icon="ant-design:send-outlined"
|
||||
size="14" color="#1F2329"></Icon>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<template v-if="item.status === 'enable'">
|
||||
<a-menu-item key="release" @click.prevent.stop="handleSendClick(item,'release')">
|
||||
<Icon icon="lineicons:rocket-5" size="14"></Icon>
|
||||
发布
|
||||
</a-menu-item>
|
||||
<a-menu-divider/>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'release'">
|
||||
<a-menu-item key="un-release" @click.prevent.stop="handleSendClick(item,'un-release')">
|
||||
<Icon icon="tabler:rocket-off" size="14"></Icon>
|
||||
取消发布
|
||||
</a-menu-item>
|
||||
<a-menu-divider/>
|
||||
</template>
|
||||
<a-menu-item key="web" @click.prevent.stop="handleSendClick(item,'web')">
|
||||
<Icon icon="ant-design:dribbble-outlined" size="16"></Icon>
|
||||
嵌入网站
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="isShowMenu" key="menu" @click.prevent.stop="handleSendClick(item,'menu')">
|
||||
<Icon icon="ant-design:menu-outlined" size="16"></Icon> 配置菜单
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<Pagination
|
||||
v-if="knowledgeAppDataList.length > 0"
|
||||
:current="pageNo"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:total="total"
|
||||
:showQuickJumper="true"
|
||||
:showSizeChanger="true"
|
||||
@change="handlePageChange"
|
||||
class="list-footer"
|
||||
size="small"
|
||||
:show-total="() => `共${total}条` "
|
||||
/>
|
||||
<!-- Ai新增弹窗 -->
|
||||
<AiAppModal @register="registerModal" @success="handleSuccess"></AiAppModal>
|
||||
<!-- Ai设置弹窗 -->
|
||||
<AiAppSettingModal @register="registerSettingModal" @success="reload"></AiAppSettingModal>
|
||||
<!-- 发布弹窗 -->
|
||||
<AiAppSendModal @register="registerAiAppSendModal"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModal, useModalInner } from '@/components/Modal';
|
||||
import { LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import { Avatar, Modal, Pagination } from 'ant-design-vue';
|
||||
import { getFileAccessHttpUrl } from '@/utils/common/compUtils';
|
||||
import defaultImg from './img/ailogo.png';
|
||||
import AiAppModal from './components/AiAppModal.vue';
|
||||
import AiAppSettingModal from './components/AiAppSettingModal.vue';
|
||||
import AiAppSendModal from './components/AiAppSendModal.vue';
|
||||
import Icon from '@/components/Icon';
|
||||
import { $electron } from "@/electron";
|
||||
import { appList, deleteApp, releaseApp } from './AiApp.api';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
import JInput from '@/components/Form/src/jeecg/components/JInput.vue';
|
||||
import JDictSelectTag from '@/components/Form/src/jeecg/components/JDictSelectTag.vue';
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
export default {
|
||||
name: 'AiAppList',
|
||||
components: {
|
||||
JDictSelectTag,
|
||||
JInput,
|
||||
AiAppSendModal,
|
||||
Icon,
|
||||
Pagination,
|
||||
Avatar,
|
||||
LoadingOutlined,
|
||||
BasicModal,
|
||||
AiAppModal,
|
||||
AiAppSettingModal,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
/**
|
||||
* 创建应用的集合
|
||||
*/
|
||||
const knowledgeAppDataList = ref<any>([]);
|
||||
//当前页数
|
||||
const pageNo = ref<number>(1);
|
||||
//每页条数
|
||||
const pageSize = ref<number>(10);
|
||||
//总条数
|
||||
const total = ref<number>(0);
|
||||
//可选择的页数
|
||||
const pageSizeOptions = ref<any>(['10', '20', '30']);
|
||||
//注册modal
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
const [registerSettingModal, { openModal: openAppModal }] = useModal();
|
||||
const [registerAiAppSendModal, { openModal: openAiAppSendModal }] = useModal();
|
||||
const { createMessage, createConfirmSync } = useMessage();
|
||||
//查询参数
|
||||
const queryParam = reactive<any>({});
|
||||
//查询区域label宽度
|
||||
const labelCol = reactive({
|
||||
xs: 24,
|
||||
sm: 4,
|
||||
xl: 6,
|
||||
xxl: 6,
|
||||
});
|
||||
//查询区域组件宽度
|
||||
const wrapperCol = reactive({
|
||||
xs: 24,
|
||||
sm: 20,
|
||||
});
|
||||
//表单的ref
|
||||
const formRef = ref();
|
||||
|
||||
reload();
|
||||
|
||||
/**
|
||||
* 加载数据
|
||||
*/
|
||||
function reload() {
|
||||
let params = {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
column: 'createTime',
|
||||
order: 'desc',
|
||||
};
|
||||
Object.assign(params, queryParam);
|
||||
appList(params).then((res) => {
|
||||
if (res.success) {
|
||||
knowledgeAppDataList.value = res.result.records;
|
||||
total.value = res.result.total;
|
||||
} else {
|
||||
knowledgeAppDataList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建应用
|
||||
*/
|
||||
function handleCreateApp() {
|
||||
openModal(true, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页改变事件
|
||||
* @param page
|
||||
* @param current
|
||||
*/
|
||||
function handlePageChange(page, current) {
|
||||
pageNo.value = page;
|
||||
pageSize.value = current;
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功
|
||||
*/
|
||||
function handleSuccess(id) {
|
||||
reload();
|
||||
//打开编辑弹窗
|
||||
openAppModal(true, {
|
||||
isUpdate: false,
|
||||
id: id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片
|
||||
* @param url
|
||||
*/
|
||||
function getImage(url) {
|
||||
return url ? getFileAccessHttpUrl(url) : defaultImg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
* @param item
|
||||
*/
|
||||
function handleEditClick(item) {
|
||||
console.log('item:::', item);
|
||||
openAppModal(true, {
|
||||
isUpdate: true,
|
||||
...item,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示
|
||||
*/
|
||||
function handleViewClick(id: string) {
|
||||
let url = '/ai/app/chat/' + id;
|
||||
|
||||
// update-begin--author:sunjianlei---date:20250411---for:【QQYUN-9685】构建 electron 桌面应用
|
||||
if ($electron.isElectron()) {
|
||||
url = $electron.resolveRoutePath(url);
|
||||
window.open(url, '_blank', 'width=1200,height=800');
|
||||
return
|
||||
}
|
||||
// update-end----author:sunjianlei---date:20250411---for:【QQYUN-9685】构建 electron 桌面应用
|
||||
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*/
|
||||
function handleDeleteClick(item) {
|
||||
if(knowledgeAppDataList.value.length == 1 && pageNo.value > 1) {
|
||||
pageNo.value = pageNo.value - 1;
|
||||
}
|
||||
deleteApp({ id: item.id, name: item.name }, reload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布点击事件
|
||||
* @param item 数据
|
||||
* @param type 类别
|
||||
*/
|
||||
function handleSendClick(item,type) {
|
||||
if (type === 'release' || type === 'un-release') {
|
||||
return onRelease(item);
|
||||
}
|
||||
|
||||
openAiAppSendModal(true,{
|
||||
type: type,
|
||||
data: item
|
||||
})
|
||||
}
|
||||
|
||||
async function onRelease(item) {
|
||||
const toRelease = item.status === 'enable';
|
||||
let flag = await createConfirmSync({
|
||||
title: toRelease ? '发布应用' : '取消发布应用',
|
||||
content: toRelease ? '确定要发布应用吗?发布后将不允许修改应用。' : '确定要取消发布应用吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
});
|
||||
if (!flag) {
|
||||
return
|
||||
}
|
||||
doRelease(item, item.status === 'enable');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布
|
||||
*/
|
||||
async function doRelease(item, release: boolean) {
|
||||
let success: boolean = await releaseApp(item.id, release);
|
||||
if (success) {
|
||||
// 发布成功
|
||||
if (release) {
|
||||
item.status = 'release'
|
||||
} else {
|
||||
item.status = 'enable'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function searchReset() {
|
||||
pageNo.value = 1;
|
||||
formRef.value.resetFields();
|
||||
queryParam.name = '';
|
||||
//刷新数据
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
function searchQuery(){
|
||||
pageNo.value = 1;
|
||||
//刷新数据
|
||||
reload();
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
//是否显示菜单配置选项
|
||||
const isShowMenu = ref<boolean>(false);
|
||||
onMounted((()=>{
|
||||
let fullPath = router.currentRoute.value.fullPath;
|
||||
console.log(fullPath)
|
||||
if(fullPath === '/myapps/ai/app'){
|
||||
isShowMenu.value = false;
|
||||
} else {
|
||||
isShowMenu.value = true;
|
||||
}
|
||||
}))
|
||||
|
||||
return {
|
||||
handleCreateApp,
|
||||
knowledgeAppDataList,
|
||||
pageNo,
|
||||
pageSize,
|
||||
total,
|
||||
pageSizeOptions,
|
||||
handlePageChange,
|
||||
cardBodyStyle: { textAlign: 'left', width: '100%' },
|
||||
registerModal,
|
||||
handleSuccess,
|
||||
getImage,
|
||||
handleEditClick,
|
||||
handleViewClick,
|
||||
handleDeleteClick,
|
||||
registerSettingModal,
|
||||
reload,
|
||||
queryParam,
|
||||
labelCol,
|
||||
wrapperCol,
|
||||
handleSendClick,
|
||||
registerAiAppSendModal,
|
||||
searchReset,
|
||||
formRef,
|
||||
isShowMenu,
|
||||
searchQuery,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.knowledge {
|
||||
height: calc(100vh - 115px);
|
||||
background: #f7f8fc;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.add-knowledge-card {
|
||||
margin-bottom: 20px;
|
||||
background: #fcfcfd;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 4px #e6e6e6;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
height: 152px;
|
||||
width: calc(100% - 20px);
|
||||
.add-knowledge-card-icon {
|
||||
padding: 8px;
|
||||
color: #1f2329;
|
||||
background-color: #f5f6f7;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.add-knowledge-card-title {
|
||||
font-size: 16px;
|
||||
color:#1f2329;
|
||||
font-weight: 400;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
border-radius: 10px;
|
||||
margin-right: 20px;
|
||||
margin-bottom: 20px;
|
||||
height: 152px;
|
||||
background: #fcfcfd;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 4px #e6e6e6;
|
||||
transition: all 0.3s ease;
|
||||
.header-img {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.header-text {
|
||||
margin-left: 5px;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
display: grid;
|
||||
width: calc(100% - 100px);
|
||||
.header-name {
|
||||
font-weight: bold;
|
||||
color: #354052;
|
||||
}
|
||||
.header-create {
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
}
|
||||
.header-tag {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-knowledge-card,
|
||||
.knowledge-card {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.add-knowledge-card:hover,
|
||||
.knowledge-card:hover {
|
||||
box-shadow: 0 6px 12px #d0d3d8;
|
||||
}
|
||||
|
||||
.knowledge-row {
|
||||
max-height: calc(100% - 100px);
|
||||
margin-top: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.add-knowledge-doc {
|
||||
margin-top: 6px;
|
||||
color: #6f6f83;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
span {
|
||||
margin-left: 4px;
|
||||
line-height: 28px;
|
||||
}
|
||||
}
|
||||
.add-knowledge-doc:hover {
|
||||
background: #c8ceda33;
|
||||
}
|
||||
.card-description {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
height: 4.5em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5;
|
||||
margin-top: 10px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: #676f83;
|
||||
}
|
||||
.card-footer {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 0;
|
||||
min-height: 30px;
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card-footer-icon {
|
||||
font-size: 14px;
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
align-content: center;
|
||||
float: left;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.card-footer-icon:hover {
|
||||
color: #000000;
|
||||
background-color: #e9ecf2;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.operation {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
.list-footer {
|
||||
text-align: right;
|
||||
margin-top: 5px;
|
||||
}
|
||||
:deep(.ant-card .ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
.ellipsis{
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.jeecg-basic-table-form-container {
|
||||
padding: 0;
|
||||
:deep(.ant-form) {
|
||||
background-color: transparent;
|
||||
}
|
||||
.table-page-search-submitButtons {
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style lang="less">
|
||||
.airag-knowledge-doc .scroll-container {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
550
jeecgboot-vue3/src/views/super/airag/aiapp/chat/AiChat.vue
Normal file
550
jeecgboot-vue3/src/views/super/airag/aiapp/chat/AiChat.vue
Normal file
@@ -0,0 +1,550 @@
|
||||
<template>
|
||||
<div ref="chatContainerRef" class="chat-container" :style="chatContainerStyle">
|
||||
<template v-if="dataSource">
|
||||
<div v-if="isMultiSession" class="leftArea" :class="[expand ? 'expand' : 'shrink']">
|
||||
<div class="content">
|
||||
<slide :source="source" v-if="uuid" :dataSource="dataSource" @save="handleSave" :prologue="prologue" :appData="appData" @click="handleChatClick"></slide>
|
||||
</div>
|
||||
<div class="toggle-btn" @click="handleToggle">
|
||||
<span class="icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.64645 3.14645C5.45118 3.34171 5.45118 3.65829 5.64645 3.85355L9.79289 8L5.64645 12.1464C5.45118 12.3417 5.45118 12.6583 5.64645 12.8536C5.84171 13.0488 6.15829 13.0488 6.35355 12.8536L10.8536 8.35355C11.0488 8.15829 11.0488 7.84171 10.8536 7.64645L6.35355 3.14645C6.15829 2.95118 5.84171 2.95118 5.64645 3.14645Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rightArea" :class="[expand ? 'expand' : 'shrink']">
|
||||
<chat
|
||||
url="/airag/chat/send"
|
||||
v-if="uuid && chatVisible"
|
||||
:uuid="uuid"
|
||||
:historyData="chatData"
|
||||
type="view"
|
||||
@save="handleSave"
|
||||
:formState="appData"
|
||||
:prologue="prologue"
|
||||
:presetQuestion="presetQuestion"
|
||||
@reload-message-title="reloadMessageTitle"
|
||||
:chatTitle="chatTitle"
|
||||
:quickCommandData="quickCommandData"
|
||||
:showAdvertising = "showAdvertising"
|
||||
:hasExtraFlowInputs="hasExtraFlowInputs"
|
||||
:conversationSettings="getCurrentSettings"
|
||||
@edit-settings="handleEditSettings"
|
||||
ref="chatRef"
|
||||
></chat>
|
||||
</div>
|
||||
<!-- [issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程 -->
|
||||
<ConversationSettingsModal
|
||||
ref="settingsModalRef"
|
||||
:flowInputs="flowInputs"
|
||||
:conversationId="uuid"
|
||||
:existingSettings="getCurrentSettings"
|
||||
@ok="handleSettingsOk"
|
||||
/>
|
||||
</template>
|
||||
<Loading :loading="loading" tip="加载中,请稍后"></Loading>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import slide from './slide.vue';
|
||||
import chat from './chat.vue';
|
||||
import ConversationSettingsModal from './components/ConversationSettingsModal.vue';
|
||||
import { Spin, message } from 'ant-design-vue';
|
||||
import { ref, watch, nextTick, onUnmounted, onMounted, computed } from 'vue';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import { JEECG_CHAT_KEY } from '/@/enums/cacheEnum';
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAppInject } from "@/hooks/web/useAppInject";
|
||||
import Loading from '@/components/Loading/src/Loading.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const userId = useUserStore().getUserInfo?.id;
|
||||
const localKey = JEECG_CHAT_KEY + userId;
|
||||
let timer: any = null;
|
||||
let unwatch01: any = null;
|
||||
const dataSource = ref<any>({});
|
||||
const uuid = ref<string>('');
|
||||
const chatData = ref<any>([]);
|
||||
const expand = ref<any>(true);
|
||||
const chatVisible = ref(true);
|
||||
const chatContainerRef = ref<any>(null);
|
||||
const chatContainerStyle = ref({});
|
||||
//左侧聊天信息
|
||||
const chatTitle = ref<string>('');
|
||||
//左侧聊天点击的坐标
|
||||
const chatActiveKey = ref<number>(0);
|
||||
//预置开场白
|
||||
const presetQuestion = ref<string>('');
|
||||
//加载
|
||||
const loading = ref<any>(true);
|
||||
|
||||
const handleToggle = () => {
|
||||
expand.value = !expand.value;
|
||||
};
|
||||
//应用id
|
||||
const appId = ref<string>('');
|
||||
//应用数据
|
||||
const appData = ref<any>({});
|
||||
//开场白
|
||||
const prologue = ref<string>('');
|
||||
//快捷指令
|
||||
const quickCommandData = ref<any>([]);
|
||||
//是否显示广告位
|
||||
const showAdvertising = ref<boolean>(false);
|
||||
//对话设置弹窗ref
|
||||
const settingsModalRef = ref();
|
||||
//工作流入参列表
|
||||
const flowInputs = ref<any[]>([]);
|
||||
//当前会话的设置
|
||||
const conversationSettings = ref<Record<string, Record<string, any>>>({});
|
||||
|
||||
const priming = () => {
|
||||
dataSource.value = {
|
||||
active: '1002',
|
||||
usingContext: true,
|
||||
history: [{ id: '1002', title: '新建聊天', isEdit: false, disabled: true }],
|
||||
};
|
||||
chatTitle.value = '新建聊天';
|
||||
chatActiveKey.value = 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// 删除标签或清空内容之后的保存
|
||||
//save(dataSource.value);
|
||||
setTimeout(() => {
|
||||
// 删除标签或清空内容也会触发watch保存,此时不需watch保存需清除
|
||||
//clearTimeout(timer);
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否有额外的工作流入参
|
||||
* for [issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程
|
||||
*/
|
||||
const hasExtraFlowInputs = computed(() => {
|
||||
if (!appData.value || !appData.value.metadata) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const metadata = typeof appData.value.metadata === 'string'
|
||||
? JSON.parse(appData.value.metadata)
|
||||
: appData.value.metadata;
|
||||
const flowInputsList = metadata.flowInputs || [];
|
||||
|
||||
// 过滤掉固定参数
|
||||
const fixedParams = ['history', 'content', 'images'];
|
||||
const extraInputs = flowInputsList.filter((input: any) => !fixedParams.includes(input.field));
|
||||
|
||||
return extraInputs.length > 0;
|
||||
} catch (e) {
|
||||
console.error('解析metadata失败', e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 检查是否有必填的额外参数
|
||||
const hasRequiredFlowInputs = computed(() => {
|
||||
if (!appData.value || !appData.value.metadata) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const metadata = typeof appData.value.metadata === 'string'
|
||||
? JSON.parse(appData.value.metadata)
|
||||
: appData.value.metadata;
|
||||
const flowInputsList = metadata.flowInputs || [];
|
||||
|
||||
// 过滤掉固定参数,且必须是必填的
|
||||
const fixedParams = ['history', 'content', 'images'];
|
||||
const requiredInputs = flowInputsList.filter((input: any) =>
|
||||
!fixedParams.includes(input.field) && input.required
|
||||
);
|
||||
|
||||
return requiredInputs.length > 0;
|
||||
} catch (e) {
|
||||
console.error('解析metadata失败', e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听appData变化,更新flowInputs
|
||||
watch(
|
||||
() => appData.value,
|
||||
(val) => {
|
||||
if (!val || !val.metadata) {
|
||||
flowInputs.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const metadata = typeof val.metadata === 'string'
|
||||
? JSON.parse(val.metadata)
|
||||
: val.metadata;
|
||||
flowInputs.value = metadata.flowInputs || [];
|
||||
} catch (e) {
|
||||
console.error('解析metadata失败', e);
|
||||
flowInputs.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// 获取当前会话的设置
|
||||
const getCurrentSettings = computed(() => {
|
||||
return conversationSettings.value[uuid.value] || {};
|
||||
});
|
||||
|
||||
// 编辑对话设置
|
||||
function handleEditSettings() {
|
||||
if (settingsModalRef.value) {
|
||||
settingsModalRef.value.open();
|
||||
}
|
||||
}
|
||||
|
||||
// 保存对话设置
|
||||
function handleSettingsOk(data: Record<string, any>) {
|
||||
// 保存到本地状态(会在发送消息时传给后端)
|
||||
conversationSettings.value[uuid.value] = data;
|
||||
message.success('对话设置已保存');
|
||||
|
||||
nextTick(() => {
|
||||
chatVisible.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
// 监听dataSource变化执行操作
|
||||
const execute = () => {
|
||||
unwatch01 = watch(
|
||||
() => dataSource.value.active,
|
||||
(value) => {
|
||||
if (value) {
|
||||
if (value == '1002') {
|
||||
uuid.value = '1002';
|
||||
chatData.value = [];
|
||||
chatTitle.value = "新建聊天";
|
||||
chatVisible.value = false;
|
||||
nextTick(() => {
|
||||
chatVisible.value = true;
|
||||
// 新会话且有必填参数,弹出设置弹窗
|
||||
if (hasRequiredFlowInputs.value && !conversationSettings.value['1002']) {
|
||||
if (settingsModalRef.value) {
|
||||
settingsModalRef.value.open();
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
//update-begin---author:wangshuai---date:2025-03-14---for:【QQYUN-11421】聊天,删除会话后,聊天切换到新的会话,但是聊天标题没有变---
|
||||
let values = dataSource.value.history.filter((item) => item.id === value);
|
||||
if(values && values.length>0){
|
||||
chatTitle.value = values[0]?.title
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-14---for:【QQYUN-11421】聊天,删除会话后,聊天切换到新的会话,但是聊天标题没有变---
|
||||
//根据选中的id查询聊天内容
|
||||
let params = { conversationId: value };
|
||||
uuid.value = value;
|
||||
defHttp.get({ url: '/airag/chat/messages', params }, { isTransformResponse: false }).then((res) => {
|
||||
if (res.success) {
|
||||
// 处理新的返回格式(包含messages和flowInputs)
|
||||
if (res.result && res.result.messages) {
|
||||
chatData.value = res.result.messages;
|
||||
// 加载已保存的设置
|
||||
if (res.result.flowInputs) {
|
||||
conversationSettings.value[value] = res.result.flowInputs;
|
||||
}
|
||||
} else if (Array.isArray(res.result)) {
|
||||
// 兼容旧格式
|
||||
chatData.value = res.result;
|
||||
} else {
|
||||
chatData.value = [];
|
||||
}
|
||||
} else {
|
||||
chatData.value = [];
|
||||
}
|
||||
chatVisible.value = false;
|
||||
// 新会话且有必填参数,弹出设置弹窗
|
||||
if (hasRequiredFlowInputs.value && !conversationSettings.value[value]) {
|
||||
if (settingsModalRef.value) {
|
||||
settingsModalRef.value.open();
|
||||
}
|
||||
}else{
|
||||
nextTick(() => {
|
||||
chatVisible.value = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}else{
|
||||
chatData.value = [];
|
||||
chatTitle.value = "";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
};
|
||||
|
||||
//是否为多会话模式
|
||||
const isMultiSession = ref<boolean>(true);
|
||||
//是否为手机
|
||||
const { getIsMobile } = useAppInject();
|
||||
//来源
|
||||
const source = ref<string>('');
|
||||
|
||||
/**
|
||||
* 初始化聊天信息
|
||||
* @param appId
|
||||
*/
|
||||
function initChartData(appId = '') {
|
||||
defHttp
|
||||
.get(
|
||||
{
|
||||
url: '/airag/chat/conversations',
|
||||
params: { appId: appId },
|
||||
},
|
||||
{ isTransformResponse: false }
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.success && res.result && res.result.length > 0) {
|
||||
dataSource.value.history = res.result;
|
||||
dataSource.value.active = res.result[0].id;
|
||||
chatTitle.value = res.result[0].title;
|
||||
chatActiveKey.value = 0;
|
||||
} else {
|
||||
priming();
|
||||
}
|
||||
!unwatch01 && execute();
|
||||
})
|
||||
.catch(() => {
|
||||
priming();
|
||||
}).finally(()=>{
|
||||
loading.value = false
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loading.value = true;
|
||||
let params: any = router.currentRoute.value.params;
|
||||
if (params.appId) {
|
||||
appId.value = params.appId;
|
||||
getApplicationData(params.appId);
|
||||
initChartData(params.appId);
|
||||
} else {
|
||||
initChartData();
|
||||
quickCommandData.value = [
|
||||
{ name: '请介绍一下JeecgBoot', descr: "请介绍一下JeecgBoot" },
|
||||
{ name: 'JEECG有哪些优势?', descr: "JEECG有哪些优势?" },
|
||||
{ name: 'JEECG可以做哪些事情?', descr: "JEECG可以做哪些事情?" },];
|
||||
}
|
||||
let query: any = router.currentRoute.value.query;
|
||||
source.value = query.source;
|
||||
if(query.source){
|
||||
showAdvertising.value = query.source === 'chatJs';
|
||||
}else{
|
||||
showAdvertising.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
chatData.value = [];
|
||||
chatTitle.value = "";
|
||||
prologue.value = ""
|
||||
presetQuestion.value = "";
|
||||
quickCommandData.value = [];
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取应用id
|
||||
*
|
||||
* @param appId
|
||||
*/
|
||||
async function getApplicationData(appId) {
|
||||
await defHttp
|
||||
.get(
|
||||
{
|
||||
url: '/airag/chat/init',
|
||||
params: { id: appId },
|
||||
},
|
||||
{ isTransformResponse: false }
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
appData.value = res.result;
|
||||
if (res.result && res.result.prologue) {
|
||||
prologue.value = res.result.prologue;
|
||||
}
|
||||
if (res.result && res.result.quickCommand) {
|
||||
quickCommandData.value = JSON.parse(res.result.quickCommand);
|
||||
}
|
||||
if (res.result && res.result.presetQuestion) {
|
||||
presetQuestion.value = res.result.presetQuestion;
|
||||
}
|
||||
if (res.result && res.result.metadata) {
|
||||
let metadata = JSON.parse(res.result.metadata);
|
||||
//判斷是否为手机模式
|
||||
if(!getIsMobile.value){
|
||||
//是否为多会话模式
|
||||
if((metadata.multiSession && metadata.multiSession === '1') || !metadata.multiSession) {
|
||||
isMultiSession.value = true;
|
||||
} else {
|
||||
isMultiSession.value = false;
|
||||
expand.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(getIsMobile.value){
|
||||
isMultiSession.value = false;
|
||||
expand.value = false;
|
||||
}
|
||||
} else {
|
||||
appData.value = {};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧消息列表点击事件
|
||||
* @param title
|
||||
* @param index
|
||||
*/
|
||||
function handleChatClick(title, index) {
|
||||
chatTitle.value = title;
|
||||
chatActiveKey.value = index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载标题消息
|
||||
* @param text
|
||||
*/
|
||||
function reloadMessageTitle(text) {
|
||||
let title = dataSource.value.history[chatActiveKey.value].title;
|
||||
if(title === '新建聊天'){
|
||||
dataSource.value.history[chatActiveKey.value].title = text;
|
||||
dataSource.value.history[chatActiveKey.value]['disabled'] = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化聊天:用于icon点击
|
||||
*/
|
||||
function initChat(value) {
|
||||
appId.value = value;
|
||||
getApplicationData(value);
|
||||
initChartData(value);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
initChat
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unwatch01 && unwatch01();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => chatContainerRef.value,
|
||||
() => {
|
||||
if(chatContainerRef.value.offsetHeight){
|
||||
chatContainerStyle.value = { height: `${chatContainerRef.value.offsetHeight} px` };
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@width: 260px;
|
||||
.chat-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
background: white;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
z-index: 800;
|
||||
border: 1px solid #eeeeee;
|
||||
:deep(.ant-spin) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.leftArea {
|
||||
width: @width;
|
||||
transition: 0.3s left;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.shrink {
|
||||
left: -@width;
|
||||
|
||||
.toggle-btn {
|
||||
.icon {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
transition:
|
||||
color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
right 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: rgb(51, 54, 57);
|
||||
border: 1px solid rgb(239, 239, 245);
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 4px 0px #e7e9ef;
|
||||
transform: translateX(50%) translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: rotate(180deg);
|
||||
font-size: 18px;
|
||||
height: 18px;
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rightArea {
|
||||
margin-left: @width;
|
||||
transition: 0.3s margin-left;
|
||||
|
||||
&.shrink {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="footer">
|
||||
<div v-if="!showChat" class="footer-icon" @click="chatClick">
|
||||
<Icon icon="ant-design:comment-outlined" size="22"></Icon>
|
||||
</div>
|
||||
<div v-if="showChat" class="footer-close-icon" @click="chatClick">
|
||||
<Icon icon="ant-design:close-outlined" size="20"></Icon>
|
||||
</div>
|
||||
<div v-if="showChat" class="ai-chat">
|
||||
<AiChat ref="aiChatRef"></AiChat>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import AiChat from './AiChat.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
//aiChat的ref
|
||||
const aiChatRef = ref();
|
||||
//应用id
|
||||
const appId = ref<string>('');
|
||||
|
||||
//是否显示聊天
|
||||
const showChat = ref<any>(false);
|
||||
const router = useRouter();
|
||||
//判断是否为初始化
|
||||
const isInit = ref<boolean>(false);
|
||||
|
||||
/**
|
||||
* chat图标点击事件
|
||||
*/
|
||||
function chatClick() {
|
||||
showChat.value = !showChat.value;
|
||||
if(showChat.value && !isInit.value){
|
||||
setTimeout(()=>{
|
||||
isInit.value = true;
|
||||
aiChatRef.value.initChat(appId.value);
|
||||
},100)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
let params: any = router.currentRoute.value.params;
|
||||
appId.value = params?.appId;
|
||||
isInit.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
left: unset;
|
||||
top: unset;
|
||||
|
||||
.footer-icon {
|
||||
cursor: pointer;
|
||||
background-color: #155eef;
|
||||
color: white;
|
||||
border-radius: 100%;
|
||||
padding: 20px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: #cccccc 0 4px 8px 0;
|
||||
}
|
||||
.footer-close-icon {
|
||||
color: #0a3069;
|
||||
height: 48px;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 9999;
|
||||
}
|
||||
.ai-chat {
|
||||
border: 1px solid #eeeeee;
|
||||
width: calc(100vh - 20px);
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
326
jeecgboot-vue3/src/views/super/airag/aiapp/chat/ThinkText.vue
Normal file
326
jeecgboot-vue3/src/views/super/airag/aiapp/chat/ThinkText.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div v-if="text != ''" class="textWrap" :class="[inversion === 'user' ? 'self' : 'chatgpt']" ref="textRef">
|
||||
<div :style="{ width: getIsMobile ? screenWidth : 'auto' }">
|
||||
<div class="markdown-body" :class="{ 'markdown-body-generate': loading }" :style="{ color: error ? '#FF4444 !important' : '' }" v-html="text" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, onUpdated, ref } from 'vue';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import './style/github-markdown.less';
|
||||
import './style/highlight.less';
|
||||
import './style/style.less';
|
||||
import { useAppInject } from '@/hooks/web/useAppInject';
|
||||
import hljs from 'highlight.js';
|
||||
import mila from 'markdown-it-link-attributes';
|
||||
import mdKatex from '@traptitech/markdown-it-katex';
|
||||
import { useGlobSetting } from '@/hooks/setting';
|
||||
|
||||
/**
|
||||
* 屏幕宽度
|
||||
*/
|
||||
const screenWidth = ref<string>();
|
||||
const { getIsMobile } = useAppInject();
|
||||
|
||||
const props = defineProps(['dateTime', 'text', 'inversion', 'error', 'loading', 'referenceKnowledge']);
|
||||
const textRef = ref();
|
||||
const mdi = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
highlight(code, language) {
|
||||
const validLang = !!(language && hljs.getLanguage(language));
|
||||
if (validLang) {
|
||||
const lang = language ?? '';
|
||||
return highlightBlock(hljs.highlight(code, { language: lang }).value, lang);
|
||||
}
|
||||
return highlightBlock(hljs.highlightAuto(code).value, '');
|
||||
},
|
||||
});
|
||||
|
||||
mdi.use(mila, { attrs: { target: '_blank', rel: 'noopener' } });
|
||||
mdi.use(mdKatex, { blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000' });
|
||||
|
||||
const text = computed(() => {
|
||||
let value = props.text ?? '';
|
||||
if (props.inversion != 'user') {
|
||||
value = replaceImageWith(value);
|
||||
value = replaceDomainUrl(value);
|
||||
return mdi.render(value);
|
||||
}
|
||||
return value.replace('\n', '<br>');
|
||||
});
|
||||
|
||||
// 是否显示引用知识库
|
||||
const showRefKnow = computed(() => {
|
||||
const { loading, referenceKnowledge } = props;
|
||||
if (loading) {
|
||||
return false;
|
||||
}
|
||||
return Array.isArray(referenceKnowledge) && referenceKnowledge.length > 0;
|
||||
});
|
||||
|
||||
//替换图片宽度
|
||||
const replaceImageWith = (markdownContent) => {
|
||||
// 支持图片设置width的写法 
|
||||
const regex = /!\[([^\]]*)\]\(([^)]+)=([0-9]+)\)/g;
|
||||
return markdownContent.replace(regex, (match, alt, src, width) => {
|
||||
let reg = /#\s*{\s*domainURL\s*}/g;
|
||||
src = src.replace(reg, domainUrl);
|
||||
return `<div><img src='${src}' alt='${alt}' width='${width}' /></div>`;
|
||||
});
|
||||
};
|
||||
const { domainUrl } = useGlobSetting();
|
||||
//替换domainURL
|
||||
const replaceDomainUrl = (markdownContent) => {
|
||||
const regex = /!\[([^\]]*)\]\(.*?#\s*{\s*domainURL\s*}.*?\)/g;
|
||||
return markdownContent.replace(regex, (match) => {
|
||||
let reg = /#\s*{\s*domainURL\s*}/g;
|
||||
return match.replace(reg, domainUrl);
|
||||
});
|
||||
};
|
||||
|
||||
//是否放大图片
|
||||
const amplifyImage = ref<boolean>(false);
|
||||
//图片地址
|
||||
const imageUrl = ref<string>('');
|
||||
|
||||
function highlightBlock(str: string, lang?: string) {
|
||||
return `<pre class="code-block-wrapper"><div class="code-block-header"><span class="code-block-header__lang">${lang}</span><span class="code-block-header__copy">复制代码</span></div><code class="hljs code-block-body ${lang}">${str}</code></pre>`;
|
||||
}
|
||||
function addCopyEvents() {
|
||||
if (textRef.value) {
|
||||
const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy');
|
||||
copyBtn.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const code = btn.parentElement?.nextElementSibling?.textContent;
|
||||
if (code) {
|
||||
copyToClip(code).then(() => {
|
||||
btn.textContent = '复制成功';
|
||||
setTimeout(() => {
|
||||
btn.textContent = '复制代码';
|
||||
}, 1e3);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeCopyEvents() {
|
||||
if (textRef.value) {
|
||||
const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy');
|
||||
copyBtn.forEach((btn) => {
|
||||
btn.removeEventListener('click', () => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加图片点击事件
|
||||
*/
|
||||
function addImageClickEvent() {
|
||||
if (textRef.value) {
|
||||
const image = textRef.value.querySelectorAll('img');
|
||||
image.forEach((img) => {
|
||||
img.addEventListener('click', () => {
|
||||
imageUrl.value = img.src;
|
||||
amplifyImage.value = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移出图片点击事件
|
||||
*/
|
||||
function removeImageClickEvent() {
|
||||
if (textRef.value) {
|
||||
const image = textRef.value.querySelectorAll('img');
|
||||
image.forEach((img) => {
|
||||
img.removeEventListener('click', () => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片隐藏
|
||||
*/
|
||||
function pictureHide() {
|
||||
amplifyImage.value = false;
|
||||
imageUrl.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置markdown body整体宽度
|
||||
*/
|
||||
function setMarkdownBodyWidth() {
|
||||
//平板
|
||||
console.log('window.innerWidth::', window.innerWidth);
|
||||
if (window.innerWidth > 600 && window.innerWidth < 1024) {
|
||||
screenWidth.value = window.innerWidth - 120 + 'px';
|
||||
} else if (window.innerWidth < 600) {
|
||||
//手机
|
||||
screenWidth.value = window.innerWidth - 60 + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addCopyEvents();
|
||||
addImageClickEvent();
|
||||
setMarkdownBodyWidth();
|
||||
window.addEventListener('resize', setMarkdownBodyWidth);
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
addCopyEvents();
|
||||
addImageClickEvent();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeCopyEvents();
|
||||
removeImageClickEvent();
|
||||
window.removeEventListener('resize', setMarkdownBodyWidth);
|
||||
});
|
||||
|
||||
function copyToClip(text: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const input: HTMLTextAreaElement = document.createElement('textarea');
|
||||
input.setAttribute('readonly', 'readonly');
|
||||
input.value = text;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
if (document.execCommand('copy')) document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
resolve(text);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.textWrap {
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
border-left: 1px solid #e1e5ea;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: linear-gradient(135deg, #ff4444, #ff914d) !important;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.self {
|
||||
// background-color: #d2f9d1;
|
||||
background-color: @primary-color;
|
||||
color: #fff;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.625;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.chatgpt {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
// 已停止下方的样式
|
||||
:deep(.markdown-body) {
|
||||
color: #333;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
line-height: 1.6;
|
||||
padding: 14px;
|
||||
|
||||
// 段落样式
|
||||
p {
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1em;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// 列表样式
|
||||
ul,
|
||||
ol {
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
padding-left: 0;
|
||||
|
||||
li {
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
// 关键词/代码高亮样式
|
||||
code {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
// 行内代码
|
||||
p code,
|
||||
li code,
|
||||
td code {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
// 代码块保持原有样式
|
||||
pre {
|
||||
code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 强调文本
|
||||
strong,
|
||||
b {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 链接样式
|
||||
a {
|
||||
color: #4183c4;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
//手机和平板下的样式
|
||||
.textWrap {
|
||||
margin-left: -40px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1741
jeecgboot-vue3/src/views/super/airag/aiapp/chat/chat.vue
Normal file
1741
jeecgboot-vue3/src/views/super/airag/aiapp/chat/chat.vue
Normal file
File diff suppressed because it is too large
Load Diff
436
jeecgboot-vue3/src/views/super/airag/aiapp/chat/chatMessage.vue
Normal file
436
jeecgboot-vue3/src/views/super/airag/aiapp/chat/chatMessage.vue
Normal file
@@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<div class="chat" :class="[inversion === 'user' ? 'self' : 'chatgpt']" v-if="getText || (props.presetQuestion && props.presetQuestion.length>0)">
|
||||
<div class="avatar" v-if="showAvatar !== 'no'">
|
||||
<img v-if="inversion === 'user'" :src="avatar()" />
|
||||
<img v-else :src="getAiImg()" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<p class="date" v-if="showAvatar !== 'no'">
|
||||
<span v-if="inversion === 'ai'" style="margin-right: 10px">{{appData.name || 'AI助手'}}</span>
|
||||
<span>{{ dateTime }}</span>
|
||||
</p>
|
||||
<div v-if="inversion === 'user' && images && images.length>0" class="images">
|
||||
<div v-for="(item,index) in images" :key="index" class="image" @click="handlePreview(item)">
|
||||
<img :src="getImageUrl(item)"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="inversion === 'user' && files && files.length>0" class="file-list">
|
||||
<div v-for="(item,index) in files" :key="index" class="file-item" @click="handleFilePreview(item?.filePath || item)">
|
||||
<div class="file-icon">
|
||||
<Icon :icon="getFileIcon(item?.filePath || item)" :color="getFileIconColor(item?.filePath || item)" size="24" />
|
||||
</div>
|
||||
<div class="file-name" :title="item.name">{{ getFileName(item?.filePath || item)}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="inversion === 'ai' && retrievalText && loading" class="retrieval">
|
||||
{{retrievalText}}
|
||||
</div>
|
||||
<div v-if="inversion === 'ai' && isCard" class="card">
|
||||
<a-row>
|
||||
<a-col :xl="6" :lg="8" :md="10" :sm="24" style="flex:1" v-for="item in getCardList()">
|
||||
<a-card class="ai-card" @click="aiCardHandleClick(item.linkUrl)">
|
||||
<div class="ai-card-title">{{item.productName}}</div>
|
||||
<div class="ai-card-img">
|
||||
<img :src="item.productImage">
|
||||
</div>
|
||||
<span class="ai-card-desc">{{item.descr}}</span>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<div v-if="inversion === 'ai' && isCardConfig" class="card">
|
||||
<a-row>
|
||||
<a-col :xl="6" :lg="8" :md="10" :sm="24" style="flex:1;margin-right: 10px;" v-for="item in getCardConfigList()">
|
||||
<CardTemplate :template-id="cardConfig?.templateId" :card-data="item" :card-config="cardConfig" @click="handleJumpClick(item)"></CardTemplate>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<div class="thinkArea" style="margin-bottom: 10px" v-if="!isCard && !isCardConfig && (eventType === 'thinking' || eventType === 'thinking_end')">
|
||||
<a-collapse v-model:activeKey="activeKey" ghost>
|
||||
<a-collapse-panel :key="uuid" :header="loading?'正在思考中':'思考结束'">
|
||||
<ThinkText :text="text" :inversion="inversion" :error="error" :loading="loading"></ThinkText>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
<div class="msgArea" v-else-if="!isCard && !isCardConfig" :class="showAvatar == 'no' ? 'hidden-avatar' : ''">
|
||||
<chatText :text="text" :inversion="inversion" :error="error" :errorMsg="errorMsg" :currentToolTag="currentToolTag" :loading="loading" :referenceKnowledge="referenceKnowledge" :isLast="isLast"></chatText>
|
||||
</div>
|
||||
<div v-if="presetQuestion" v-for="item in presetQuestion" class="question" @click="presetQuestionClick(item.descr)">
|
||||
<span>{{item.descr}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import chatText from './chatText.vue';
|
||||
import ThinkText from './ThinkText.vue';
|
||||
import defaultAvatar from "@/assets/images/ai/avatar.jpg";
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import defaultImg from '../img/ailogo.png';
|
||||
import { ref } from 'vue';
|
||||
import { buildUUID } from '/@/utils/uuid';
|
||||
import { getFileAccessHttpUrl, getFileIcon, getFileIconColor } from '/@/utils/common/compUtils';
|
||||
import { createImgPreview } from "@/components/Preview";
|
||||
import { computed } from "vue";
|
||||
import CardTemplate from '/@/views/super/airag/aiapp/chat/components/CardTemplate.vue';
|
||||
import { useGlobSetting } from "@/hooks/setting";
|
||||
import {encryptByBase64} from "@/utils/cipher";
|
||||
|
||||
const { domainUrl, viewUrl } = useGlobSetting();
|
||||
const props = defineProps(['dateTime', 'text', 'inversion', 'error', 'loading','errorMsg', 'currentToolTag', 'appData','presetQuestion','images','retrievalText', 'referenceKnowledge', 'eventType', 'showAvatar',"files", 'isLast']);
|
||||
|
||||
const uuid = ref<any>(buildUUID());
|
||||
const activeKey = ref<any>(uuid.value);
|
||||
const getText = computed(()=>{
|
||||
let text = props.text || props.retrievalText;
|
||||
if(text){
|
||||
text = text.trim();
|
||||
}
|
||||
return text;
|
||||
})
|
||||
|
||||
const isCard = computed(() => {
|
||||
let text = props.text;
|
||||
if (text && text.indexOf('::card::') != -1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isCardConfig = computed(() => {
|
||||
let text = props.text;
|
||||
if (text && text.indexOf('::cardConfig::') != -1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
//卡片配置
|
||||
const cardConfig = ref<any>();
|
||||
|
||||
const { userInfo } = useUserStore();
|
||||
const avatar = () => {
|
||||
return getFileAccessHttpUrl(userInfo?.avatar) || defaultAvatar;
|
||||
};
|
||||
const emit = defineEmits(['send']);
|
||||
const getAiImg = () => {
|
||||
return getFileAccessHttpUrl(props.appData?.icon) || defaultImg;
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设问题点击事件
|
||||
*
|
||||
*/
|
||||
function presetQuestionClick(descr) {
|
||||
emit("send",descr)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片
|
||||
*
|
||||
* @param item
|
||||
*/
|
||||
function getImageUrl(item) {
|
||||
let url = item;
|
||||
if(item.hasOwnProperty('url')){
|
||||
url = item.url;
|
||||
}
|
||||
if(item.hasOwnProperty('base64Data') && item.base64Data){
|
||||
let mimeType = item.mimeType ? item.mimeType:'image/png';
|
||||
return "data:"+ mimeType +";base64,"+ item.base64Data;
|
||||
}
|
||||
return getFileAccessHttpUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预览
|
||||
* @param url
|
||||
*/
|
||||
function handlePreview(url){
|
||||
const onImgLoad = ({ index, url, dom }) => {
|
||||
console.log(`第${index + 1}张图片已加载,URL为:${url}`, dom);
|
||||
};
|
||||
let imageList = [getImageUrl(url)];
|
||||
createImgPreview({ imageList: imageList, defaultWidth: 700, rememberState: true, onImgLoad });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取卡片列表
|
||||
*/
|
||||
function getCardList() {
|
||||
let text = props.text;
|
||||
let card = text.replace('::card::', '').replace(/\s+/g, '');
|
||||
try {
|
||||
return JSON.parse(card);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ai卡片点击事件
|
||||
* @param url
|
||||
*/
|
||||
function aiCardHandleClick(url){
|
||||
window.open(url,'_blank');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从config获取取卡片列表
|
||||
*/
|
||||
function getCardConfigList() {
|
||||
let text = props.text;
|
||||
let card = text.replace('::cardConfig::', 'cardConfig').replace(/\s+/g, '');
|
||||
try {
|
||||
let parse = JSON.parse(card);
|
||||
cardConfig.value = JSON.parse(parse?.cardConfig);
|
||||
return JSON.parse(parse?.content);
|
||||
} catch (e){
|
||||
console.log(e)
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片点击跳转
|
||||
*/
|
||||
function handleJumpClick(item) {
|
||||
if(cardConfig.value?.enableJump){
|
||||
let src = item[cardConfig.value?.jumpUrl];
|
||||
let reg = /#\s*{\s*domainURL\s*}/g;
|
||||
src = src.replace(reg,domainUrl);
|
||||
window.open(src,"_blank")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件名字
|
||||
*
|
||||
* @param fileUrl
|
||||
*/
|
||||
function getFileName(fileUrl){
|
||||
if(!fileUrl) {
|
||||
return '未命名的文件';
|
||||
}
|
||||
let fileName = fileUrl.substring(fileUrl.lastIndexOf('/') + 1).toLowerCase();
|
||||
fileName = fileName.substring(0,fileName.lastIndexOf("."));
|
||||
return fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件预览
|
||||
*
|
||||
* @param fileUrl
|
||||
*/
|
||||
function handleFilePreview(fileUrl) {
|
||||
let filePath = encodeURIComponent(encryptByBase64(getFileAccessHttpUrl(fileUrl)));
|
||||
let url = `${viewUrl}?url=` + filePath;
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.chat {
|
||||
display: flex;
|
||||
margin-bottom: 1.5rem;
|
||||
&.self {
|
||||
flex-direction: row-reverse;
|
||||
.avatar {
|
||||
margin-right: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.msgArea {
|
||||
flex-direction: row-reverse;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.thinkArea{
|
||||
margin: 0;
|
||||
padding: 5px 0 5px 22px;
|
||||
position: relative;
|
||||
}
|
||||
.date {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
:deep(.ant-collapse-header){
|
||||
padding: 0 !important;
|
||||
}
|
||||
.hidden-avatar{
|
||||
left: 44px;
|
||||
position: relative;
|
||||
top: -18px;
|
||||
}
|
||||
.avatar {
|
||||
flex: none;
|
||||
margin-right: 10px;
|
||||
img {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
svg {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
.chat.chatgpt .avatar img{
|
||||
border-radius: 4px;
|
||||
}
|
||||
.content {
|
||||
width: 90%;
|
||||
.date {
|
||||
color: #b4bbc4;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.msgArea {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.question{
|
||||
margin-top: 10px;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
background-color: #ffffff;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 4px #e6e6e6;
|
||||
}
|
||||
|
||||
.images{
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: end;
|
||||
.image{
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
cursor: pointer;
|
||||
img{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*begin文件列表的样式*/
|
||||
.file-list {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f4f6f8;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
|
||||
.file-icon {
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
/*end文件列表的样式*/
|
||||
|
||||
.retrieval,
|
||||
.card {
|
||||
background-color: #f4f6f8;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.retrieval:after{
|
||||
animation: blink 1s steps(5, start) infinite;
|
||||
color: #000;
|
||||
content: '_';
|
||||
font-weight: 700;
|
||||
margin-left: 3px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.card{
|
||||
width: 100%;
|
||||
background-color: unset;
|
||||
}
|
||||
.ai-card{
|
||||
width: 98%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
.ai-card-title{
|
||||
width: 100%;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
text-align: left;
|
||||
color: #191919;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
.ai-card-img{
|
||||
margin-top: 10px;
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
}
|
||||
.ai-card-desc{
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0;
|
||||
white-space: pre-line;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
color: #666f;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.content{
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
466
jeecgboot-vue3/src/views/super/airag/aiapp/chat/chatText.vue
Normal file
466
jeecgboot-vue3/src/views/super/airag/aiapp/chat/chatText.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<div v-if="parsedText != ''" class="textWrap" :class="[inversion === 'user' ? 'self' : (isOnlyImage ? 'chatgpt-image' : 'chatgpt')]" ref="textRef">
|
||||
<div v-if="inversion != 'user'" :style="{ width: getIsMobile? screenWidth : 'auto' }">
|
||||
<div ref="markdownBodyRef" class="markdown-body" :class="{ 'markdown-body-generate': loading }" v-html="parsedText" />
|
||||
<template v-if="showRefKnow">
|
||||
<a-divider orientation="left">引用</a-divider>
|
||||
<template v-for="(item, idx) in referenceKnowledge" :key="idx">
|
||||
<a-tooltip :title="item.content?.substring(0, 800)">
|
||||
<a-tag style="min-width: 80px;background: #F7F8FA;padding-inline: 0 7px">
|
||||
<a-space style="min-height: 30px;padding-left: 4px;padding-right: 4px;background-color: #F0F1F6;color: #788194">
|
||||
<div>{{ 'chunk-' + item.chunk}}</div>
|
||||
</a-space>
|
||||
<a-space style="min-height: 30px;padding-left: 4px;">
|
||||
<img :src="knowledgePng" width="14" height="14" style="position: relative; top: -2px"/>
|
||||
<div style="max-width: 240px; overflow: hidden;white-space: nowrap;text-overflow: ellipsis;">
|
||||
{{ item.docName }}
|
||||
</div>
|
||||
</a-space>
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="error" class="error-message">
|
||||
<p>{{ errorMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="msg" v-html="parsedText" />
|
||||
</div>
|
||||
<ImageViewer v-if="amplifyImage" :imageUrl="imageUrl" @hide="pictureHide"></ImageViewer>
|
||||
<!-- 聊天中渲染JeecgTag -->
|
||||
<template v-if="jeecgTagList.length">
|
||||
<template v-for="item of jeecgTagList" :key="item.key">
|
||||
<teleport :to="item.to">
|
||||
<Component :is="item.tag.component" :data="item.data" :loading="loading" />
|
||||
</teleport>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { JeecgTag } from './jeecg-tags/types';
|
||||
import { computed, nextTick, onMounted, onUnmounted, onUpdated, ref, watch } from 'vue';
|
||||
import * as lodash from 'lodash';
|
||||
import md5 from 'crypto-js/md5';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import mdKatex from '@traptitech/markdown-it-katex';
|
||||
import mila from 'markdown-it-link-attributes';
|
||||
import hljs from 'highlight.js';
|
||||
import './style/github-markdown.less';
|
||||
import './style/highlight.less';
|
||||
import './style/style.less';
|
||||
import ImageViewer from '@/views/super/airag/aiapp/chat/components/ImageViewer.vue';
|
||||
import { useAppInject } from "@/hooks/web/useAppInject";
|
||||
import { useGlobSetting } from "@/hooks/setting";
|
||||
import { mdPluginJeecgTag, JEECG_TAG_CLASS, jeecgTagMap } from './jeecg-tags'
|
||||
import knowledgePng from '../../aiknowledge/icon/knowledge.png'
|
||||
|
||||
/**
|
||||
* 屏幕宽度
|
||||
*/
|
||||
const screenWidth = ref<string>();
|
||||
const { domainUrl } = useGlobSetting();
|
||||
const { getIsMobile } = useAppInject();
|
||||
|
||||
const props = defineProps(['dateTime', 'text', 'inversion', 'error', 'errorMsg', 'currentToolTag', 'loading', 'referenceKnowledge', 'isLast']);
|
||||
const textRef = ref();
|
||||
const markdownBodyRef = ref<HTMLDivElement>();
|
||||
|
||||
//图片地址
|
||||
const imageUrl = ref<string>('');
|
||||
//是否放大图片
|
||||
const amplifyImage = ref<boolean>(false);
|
||||
|
||||
const parsedText = ref<string>('');
|
||||
|
||||
// 解析出来的 jeecgTag 列表
|
||||
const jeecgTagList = ref<{
|
||||
key: string;
|
||||
to: HTMLDivElement;
|
||||
tag: JeecgTag;
|
||||
data: string;
|
||||
}[]>([]);
|
||||
|
||||
const mdi = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
highlight(code, language) {
|
||||
const validLang = !!(language && hljs.getLanguage(language));
|
||||
if (validLang) {
|
||||
const lang = language ?? '';
|
||||
return highlightBlock(hljs.highlight(code, { language: lang }).value, lang);
|
||||
}
|
||||
return highlightBlock(hljs.highlightAuto(code).value, '');
|
||||
},
|
||||
});
|
||||
|
||||
// 自定义 mdi 插件
|
||||
mdi.use(mdPluginJeecgTag);
|
||||
// 官方 mdi 插件
|
||||
mdi.use(mila, { attrs: { target: '_blank', rel: 'noopener' } });
|
||||
mdi.use(mdKatex, { blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000' });
|
||||
|
||||
/**
|
||||
* 处理聊天文本并在一定时间内节流更新
|
||||
*/
|
||||
const updateTextContent = lodash.throttle(() => {
|
||||
let value = props.text ?? '';
|
||||
if (props.inversion !== 'user') {
|
||||
// 先替换图片宽度与域名占位符后再渲染 markdown
|
||||
value = replaceImageWith(value);
|
||||
value = replaceDomainUrl(value);
|
||||
parsedText.value = mdi.render(value);
|
||||
// 解析 jeecgTag 标签
|
||||
parseJeecgTag();
|
||||
return;
|
||||
}
|
||||
// 用户消息保留换行展示
|
||||
parsedText.value = value.replace("\n", "<br>");
|
||||
}, 100);
|
||||
|
||||
// 是否显示引用知识库
|
||||
const showRefKnow = computed(() => {
|
||||
const {loading, referenceKnowledge} = props
|
||||
if (loading) {
|
||||
return false;
|
||||
}
|
||||
return Array.isArray(referenceKnowledge) && referenceKnowledge.length > 0;
|
||||
})
|
||||
|
||||
// 判断是否只有图片
|
||||
const isOnlyImage = computed(() => {
|
||||
if (showRefKnow.value){
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = props.text || '';
|
||||
if (!content){
|
||||
return false;
|
||||
}
|
||||
|
||||
//匹配
|
||||
const imageRegex = /!\[.*?\]\(.*?\)/g;
|
||||
if (!imageRegex.test(content)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//替换之后是否存在文本
|
||||
const remaining = content.replace(imageRegex, '').trim();
|
||||
return remaining.length === 0;
|
||||
});
|
||||
|
||||
// 监听文本变化,触发界面更新
|
||||
watch(() => props.text, () => updateTextContent(), {immediate: true});
|
||||
|
||||
// 监听 当前调用的工具 变化,追加渲染内容
|
||||
watch(() => props.currentToolTag, () => {
|
||||
const {isLast, inversion, currentToolTag, loading} = props;
|
||||
if (isLast && inversion != 'user' && currentToolTag && loading) {
|
||||
parsedText.value += mdi.render(currentToolTag);
|
||||
// 解析 jeecgTag 标签
|
||||
parseJeecgTag();
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
//替换图片宽度
|
||||
function replaceImageWith(markdownContent) {
|
||||
//update-begin---author:wangshuai---date:2026-01-08---for: 兼容返回多张图片集图片默认宽度调整---
|
||||
// 1. 支持图片设置width的写法 
|
||||
// 必须有空格,避免匹配到url参数中的=
|
||||
const regex = /!\[([^\]]*)\]\(([^)]+)\s=([0-9]+)\)/g;
|
||||
markdownContent = markdownContent.replace(regex, (match, alt, src, width) => {
|
||||
let reg = /#\s*{\s*domainURL\s*}/g;
|
||||
src = src.replace(reg,domainUrl);
|
||||
return `<div class="chat-image-custom"><img src='${src}' alt='${alt}' width='${width}' /></div>`;
|
||||
});
|
||||
|
||||
// 2. 处理普通图片,实现多图并列显示(如生成图片场景)
|
||||
// 统计剩余的markdown图片数量
|
||||
const regexStandard = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const matches = markdownContent.match(regexStandard);
|
||||
const count = matches ? matches.length : 0;
|
||||
|
||||
if (count > 0) {
|
||||
markdownContent = markdownContent.replace(regexStandard, (match, alt, src) => {
|
||||
let reg = /#\s*{\s*domainURL\s*}/g;
|
||||
src = src.replace(reg, domainUrl);
|
||||
// 如果有多张图片,使用Grid布局(一行4个)
|
||||
if (count > 1) {
|
||||
return `<div class="chat-image-grid-item"><img src='${src}' alt='${alt}' /></div>`;
|
||||
}
|
||||
// 单张图片保持默认(或包裹以便控制)
|
||||
return `<div class="chat-image-single"><img src='${src}' alt='${alt}' /></div>`;
|
||||
});
|
||||
}
|
||||
return markdownContent;
|
||||
//update-end---author:wangshuai---date:2026-01-08---for: 兼容返回多张图片集图片默认宽度调整---
|
||||
};
|
||||
|
||||
//替换domainURL
|
||||
function replaceDomainUrl(markdownContent) {
|
||||
const regex = /!\[([^\]]*)\]\(.*?#\s*{\s*domainURL\s*}.*?\)/g;
|
||||
return markdownContent.replace(regex, (match) => {
|
||||
let reg = /#\s*{\s*domainURL\s*}/g;
|
||||
return match.replace(reg,domainUrl);
|
||||
})
|
||||
}
|
||||
|
||||
function highlightBlock(str: string, lang?: string) {
|
||||
return `<pre class="code-block-wrapper"><div class="code-block-header"><span class="code-block-header__lang">${lang}</span><span class="code-block-header__copy">复制代码</span></div><code class="hljs code-block-body ${lang}">${str}</code></pre>`;
|
||||
}
|
||||
function addCopyEvents() {
|
||||
if (textRef.value) {
|
||||
const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy');
|
||||
copyBtn.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const code = btn.parentElement?.nextElementSibling?.textContent;
|
||||
if (code) {
|
||||
copyToClip(code).then(() => {
|
||||
btn.textContent = '复制成功';
|
||||
setTimeout(() => {
|
||||
btn.textContent = '复制代码';
|
||||
}, 1e3);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeCopyEvents() {
|
||||
if (textRef.value) {
|
||||
const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy');
|
||||
copyBtn.forEach((btn) => {
|
||||
btn.removeEventListener('click', () => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 添加图片点击事件
|
||||
*/
|
||||
function addImageClickEvent() {
|
||||
if (textRef.value) {
|
||||
const image = textRef.value.querySelectorAll('img');
|
||||
image.forEach((img) => {
|
||||
img.addEventListener('click', () => {
|
||||
imageUrl.value = img.src;
|
||||
amplifyImage.value = true;
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 移出图片点击事件
|
||||
*/
|
||||
function removeImageClickEvent(){
|
||||
if (textRef.value) {
|
||||
const image = textRef.value.querySelectorAll('img');
|
||||
image.forEach((img) => {
|
||||
img.removeEventListener('click', () => { })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片隐藏
|
||||
*/
|
||||
function pictureHide(){
|
||||
amplifyImage.value = false;
|
||||
imageUrl.value = ""
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 设置markdown body整体宽度
|
||||
*/
|
||||
function setMarkdownBodyWidth() {
|
||||
//平板
|
||||
console.log("window.innerWidth::",window.innerWidth)
|
||||
if(window.innerWidth>600 && window.innerWidth<1024){
|
||||
screenWidth.value = window.innerWidth - 120 + 'px';
|
||||
}else if(window.innerWidth < 600){
|
||||
//手机
|
||||
screenWidth.value = window.innerWidth - 60 + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
// 解析JeecgTag标签
|
||||
async function parseJeecgTag() {
|
||||
await nextTick();
|
||||
if (!markdownBodyRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
jeecgTagList.value = [];
|
||||
const elements = markdownBodyRef.value.querySelectorAll('.' + JEECG_TAG_CLASS);
|
||||
elements.forEach((el) => {
|
||||
const tagName = el.nodeName.toLowerCase();
|
||||
const tag = jeecgTagMap.get(tagName);
|
||||
if (!tag) {
|
||||
console.warn(`未识别的 jeecg 标签:`, tagName, el);
|
||||
return;
|
||||
}
|
||||
const renderEl = el.querySelector('render') as HTMLDivElement;
|
||||
if (!renderEl) {
|
||||
if (props.loading) {
|
||||
// 渲染中
|
||||
el.innerHTML = `<div style="color: #888; margin-top: 12px;">图表渲染中,请稍候...</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<div style="color: red;">模型返回的图表渲染格式不正确,请优化提示词或重新尝试。</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const dataEl = el.querySelector('data');
|
||||
const dataStr = dataEl?.textContent || '';
|
||||
renderJeecgTag(tag, dataStr, renderEl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交渲染 jeecg 标签
|
||||
*/
|
||||
function renderJeecgTag(tag: JeecgTag, data: string, renderEl: HTMLDivElement) {
|
||||
jeecgTagList.value.push({
|
||||
key: md5(tag.name + '_' + data).toString(),
|
||||
to: renderEl,
|
||||
tag: tag,
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addCopyEvents();
|
||||
addImageClickEvent();
|
||||
setMarkdownBodyWidth();
|
||||
window.addEventListener('resize', setMarkdownBodyWidth);
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
addCopyEvents();
|
||||
addImageClickEvent();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeCopyEvents();
|
||||
removeImageClickEvent();
|
||||
window.removeEventListener('resize', setMarkdownBodyWidth);
|
||||
});
|
||||
|
||||
function copyToClip(text: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const input: HTMLTextAreaElement = document.createElement('textarea');
|
||||
input.setAttribute('readonly', 'readonly');
|
||||
input.value = text;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
if (document.execCommand('copy')) document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
resolve(text);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.textWrap {
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: linear-gradient(135deg, #FF4444, #FF914D) !important;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #FF4444 !important
|
||||
}
|
||||
|
||||
.self {
|
||||
// background-color: #d2f9d1;
|
||||
background-color: @primary-color;
|
||||
color: #fff;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.625;
|
||||
min-width: 20px;
|
||||
}
|
||||
.chatgpt {
|
||||
background-color: #f4f6f8;
|
||||
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.chatgpt-image {
|
||||
.markdown-body{
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
//手机和平板下的样式
|
||||
.textWrap{
|
||||
margin-left: -40px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成图片的样式
|
||||
:deep(.chat-image-grid-item) {
|
||||
display: inline-block;
|
||||
width: 24%;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
vertical-align: top;
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
object-fit: cover;
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
}
|
||||
:deep(.chat-image-single) {
|
||||
img {
|
||||
max-width: 50%;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
:deep(.jeecg-tag) {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
|
||||
data {
|
||||
display: none;
|
||||
}
|
||||
|
||||
render {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<!-- card 官方模板 -->
|
||||
<template>
|
||||
<div class="card-select-panel" @click="handleClick">
|
||||
<!-- 卡片内容 -->
|
||||
<div class="card-item">
|
||||
<div class="card-title">{{ getTitle }}</div>
|
||||
<!-- 缩略图左侧变体 -->
|
||||
<div v-if="getTemplateId === 'template-1'" class="card-top">
|
||||
<div class="thumb" v-if="!getImage">
|
||||
<div class="thumb-dot"></div>
|
||||
<div class="thumb-mountain"></div>
|
||||
</div>
|
||||
<div v-else class="thumb-image">
|
||||
<img :src="getImage" />
|
||||
</div>
|
||||
<div class="desc clamp">
|
||||
{{ getContent }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缩略图右侧变体 -->
|
||||
<div v-else-if="getTemplateId === 'template-2'" class="card-top">
|
||||
<div class="desc clamp">
|
||||
{{ getContent }}
|
||||
</div>
|
||||
<div class="thumb" v-if="!getImage">
|
||||
<div class="thumb-dot"></div>
|
||||
<div class="thumb-mountain"></div>
|
||||
</div>
|
||||
<div v-else class="thumb-image">
|
||||
<img :src="getImage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 横幅图片变体 -->
|
||||
<div v-else-if="getTemplateId === 'template-3'">
|
||||
<div class="banner" v-if="!getImage">
|
||||
<div class="banner-dot"></div>
|
||||
<div class="banner-mountain"></div>
|
||||
</div>
|
||||
<div v-else class="banner-image">
|
||||
<img :src="getImage" />
|
||||
</div>
|
||||
<div class="desc">
|
||||
{{ getContent }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 纯文本变体 -->
|
||||
<div v-else-if="getTemplateId === 'template-4'" class="desc">
|
||||
{{ getContent }}
|
||||
</div>
|
||||
<div v-if="showDeleteIcon" class="delete">
|
||||
<Icon icon="ant-design:close-outlined" @click.parent.stop="handleDelete"></Icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps, defineEmits, computed } from 'vue';
|
||||
import {useGlobSetting} from "@/hooks/setting";
|
||||
const props = defineProps({
|
||||
templateId: { type: String, default: '' },
|
||||
showDeleteIcon: { type: Boolean, default: false },
|
||||
cardData: { type: Object, default: null },
|
||||
cardConfig: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(['register', 'click', 'delete']);
|
||||
const { domainUrl } = useGlobSetting();
|
||||
const content = ref<string>('内容描述是一种重要的沟通和表达,它在描述事物时发挥着至关重要的作用');
|
||||
|
||||
//获取模板id
|
||||
const getTemplateId = computed(() => props.templateId);
|
||||
|
||||
//获取内容
|
||||
const getContent = computed(() => {
|
||||
if (props.cardData && props.cardConfig) {
|
||||
return props.cardData[props.cardConfig?.content];
|
||||
}
|
||||
return content;
|
||||
});
|
||||
|
||||
//获取文本
|
||||
const getTitle = computed(() => {
|
||||
if (props.cardData && props.cardConfig) {
|
||||
return props.cardData[props.cardConfig?.title];
|
||||
}
|
||||
return '标题';
|
||||
});
|
||||
|
||||
//获取图片
|
||||
const getImage = computed(() => {
|
||||
if (props.cardData && props.cardConfig) {
|
||||
let src = props.cardData[props.cardConfig?.image];
|
||||
let reg = /#\s*{\s*domainURL\s*}/g;
|
||||
src = src.replace(reg,domainUrl);
|
||||
return src;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
/**
|
||||
* 卡片点击事件
|
||||
*/
|
||||
function handleClick() {
|
||||
emit('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片删除事件
|
||||
*/
|
||||
function handleDelete() {
|
||||
emit('delete');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.card-select-panel {
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 标题 */
|
||||
.card-title {
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 10px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0;
|
||||
white-space: pre-line;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 18px;
|
||||
text-align: left;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
/* 描述文本 */
|
||||
.desc {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 多行省略,用于第一张卡片(示例) */
|
||||
.clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部缩略图行(缩略图 + 描述) */
|
||||
.card-top {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
/* 卡片容器 */
|
||||
.card-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e9edf3;
|
||||
padding: 14px 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 轻微内阴影与悬浮效果 */
|
||||
.card-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-item:hover {
|
||||
border-color: #dbe3ea;
|
||||
background: #f9fbfd;
|
||||
.delete {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
/* 描述文本 */
|
||||
.desc {
|
||||
color: #667085;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0;
|
||||
white-space: pre-line;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
/* 缩略图占位(与示例一致:左上小图) */
|
||||
.thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #edf2fa 0%, #e9eef7 100%);
|
||||
position: relative;
|
||||
flex: 0 0 44px;
|
||||
}
|
||||
.thumb-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 9px;
|
||||
}
|
||||
.thumb-mountain {
|
||||
width: 20px;
|
||||
height: 12px;
|
||||
background: #dbe2ee;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
bottom: 9px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
/* 横幅图片占位(第三张卡片的大图) */
|
||||
.banner {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #eef3fb 0%, #e7edf6 100%);
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.banner-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
left: 26px;
|
||||
}
|
||||
.banner-mountain {
|
||||
width: 120px;
|
||||
height: 20px;
|
||||
background: #dbe2ee;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* 变体微调:确保整体间距与示例一致 */
|
||||
.variant-text .desc {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.variant-thumb .desc {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.variant-banner .desc {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.delete {
|
||||
width: 20px;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thumb-image {
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
flex: 0 0 44px;
|
||||
img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
.banner-image {
|
||||
background-color: transparent;
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
title="对话设置"
|
||||
:width="600"
|
||||
:maskClosable="false"
|
||||
:keyboard="false"
|
||||
@ok="handleOk"
|
||||
okText="开始对话"
|
||||
:cancelButtonProps="{ style: { display: 'none' } }"
|
||||
:okButtonProps="{ disabled: !canSubmit }"
|
||||
>
|
||||
<div class="settings-content">
|
||||
<a-form :model="formData" layout="vertical">
|
||||
<a-form-item
|
||||
v-for="input in flowInputs"
|
||||
:key="input.field"
|
||||
:label="input.name"
|
||||
:required="input.required"
|
||||
>
|
||||
<!-- 文本类型 - 使用单行输入 -->
|
||||
<a-input
|
||||
v-if="input.type === 'string'"
|
||||
v-model:value="formData[input.field]"
|
||||
:placeholder="`请输入${input.name}`"
|
||||
:maxlength="input.maxLength"
|
||||
show-count
|
||||
/>
|
||||
<!-- 数字类型 -->
|
||||
<a-input-number
|
||||
v-else-if="input.type === 'number'"
|
||||
v-model:value="formData[input.field]"
|
||||
:placeholder="`请输入${input.name}`"
|
||||
style="width: 100%"
|
||||
:min="input.min"
|
||||
:max="input.max"
|
||||
/>
|
||||
|
||||
<!-- 图片类型 - 使用类似chat.vue的上传样式,固定支持3张图片 -->
|
||||
<div v-else-if="input.type === 'picture'" class="image-upload-container">
|
||||
<div class="image-list-wrapper">
|
||||
<!-- 已上传的图片 -->
|
||||
<div v-for="(img, idx) in imageFileList[input.field]" :key="idx" class="image-preview-item">
|
||||
<img :src="getImageUrl(img)" @click="handlePreview(img)" />
|
||||
<div class="image-remove-icon" @click="handleRemove(idx, input.field)">
|
||||
<Icon icon="ant-design:close-outlined" :size="12" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 上传按钮 -->
|
||||
<a-upload
|
||||
v-if="(imageFileList[input.field]?.length || 0) < 3"
|
||||
accept=".jpg,.jpeg,.png"
|
||||
name="file"
|
||||
:showUploadList="false"
|
||||
:headers="headers"
|
||||
:beforeUpload="(file) => beforeUpload(file, input.field)"
|
||||
@change="(info) => handleChange(info, input.field)"
|
||||
:multiple="true"
|
||||
:action="uploadUrl"
|
||||
:max-count="3"
|
||||
>
|
||||
<div class="upload-trigger">
|
||||
<Icon icon="ant-design:plus-outlined" :size="20" />
|
||||
<div class="upload-text">上传图片</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 字符串数组类型(如history) -->
|
||||
<a-select
|
||||
v-else-if="input.type === 'string[]'"
|
||||
v-model:value="formData[input.field]"
|
||||
mode="tags"
|
||||
:placeholder="`请输入${input.name}`"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<!-- 其他类型使用文本输入 -->
|
||||
<a-input
|
||||
v-else
|
||||
v-model:value="formData[input.field]"
|
||||
:placeholder="`请输入${input.name}`"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { getFileAccessHttpUrl, getHeaders } from '@/utils/common/compUtils';
|
||||
import { useGlobSetting } from '@/hooks/setting';
|
||||
|
||||
const props = defineProps<{
|
||||
flowInputs: any[];
|
||||
conversationId: string;
|
||||
existingSettings?: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'ok', data: Record<string, any>): void;
|
||||
(e: 'cancel'): void;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const formData = ref<Record<string, any>>({});
|
||||
const imageFileList = ref<Record<string, any[]>>({});
|
||||
|
||||
const globSetting = useGlobSetting();
|
||||
const baseUploadUrl = globSetting.uploadUrl;
|
||||
const uploadUrl = `${baseUploadUrl}/airag/chat/upload`;
|
||||
const headers = getHeaders();
|
||||
|
||||
// 过滤掉固定参数(history, content, images)
|
||||
const flowInputs = computed(() => {
|
||||
const fixedParams = ['history', 'content', 'images'];
|
||||
return props.flowInputs?.filter((input) => !fixedParams.includes(input.field)) || [];
|
||||
});
|
||||
|
||||
// 检查是否可以提交(必填项都已填写)
|
||||
const canSubmit = computed(() => {
|
||||
if (flowInputs.value.length === 0) return true;
|
||||
return flowInputs.value.every((input) => {
|
||||
if (!input.required) return true;
|
||||
const value = formData.value[input.field];
|
||||
if (input.type === 'picture') {
|
||||
return imageFileList.value[input.field] && imageFileList.value[input.field].length > 0;
|
||||
}
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
});
|
||||
});
|
||||
|
||||
// 上传前处理
|
||||
const beforeUpload = (file: any, field: string) => {
|
||||
const isImage = file.type?.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentCount = imageFileList.value[field]?.length || 0;
|
||||
|
||||
if (currentCount >= 3) {
|
||||
message.warning('最多只能上传3张图片!');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 上传状态变化处理
|
||||
const handleChange = (info: any, field: string) => {
|
||||
const { file } = info;
|
||||
|
||||
if (file.status === 'error' || (file.response && file.response.code === 500)) {
|
||||
message.error(file.response?.message || `${file.name} 上传失败`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.status === 'done' && file.response) {
|
||||
const imageUrl = file.response.message;
|
||||
if (!imageFileList.value[field]) {
|
||||
imageFileList.value[field] = [];
|
||||
}
|
||||
|
||||
// 检查是否已达到上限
|
||||
if (imageFileList.value[field].length >= 3) {
|
||||
message.warning('最多只能上传3张图片!');
|
||||
return;
|
||||
}
|
||||
|
||||
imageFileList.value[field].push(imageUrl);
|
||||
|
||||
// 图片类型始终作为数组存储,触发响应式更新
|
||||
imageFileList.value = { ...imageFileList.value };
|
||||
formData.value[field] = [...imageFileList.value[field]];
|
||||
|
||||
console.log(`[图片上传] 当前图片数量: ${imageFileList.value[field].length}`, imageFileList.value[field]);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图片URL
|
||||
const getImageUrl = (img: any) => {
|
||||
if (typeof img === 'string') {
|
||||
return getFileAccessHttpUrl(img);
|
||||
}
|
||||
return getFileAccessHttpUrl(img.url || img);
|
||||
};
|
||||
|
||||
// 图片预览
|
||||
const handlePreview = (img: any) => {
|
||||
const url = typeof img === 'string' ? img : (img.url || img);
|
||||
const imageUrl = getFileAccessHttpUrl(url);
|
||||
// 可以使用 ant-design-vue 的 Image 预览功能
|
||||
window.open(imageUrl, '_blank');
|
||||
};
|
||||
|
||||
// 移除图片
|
||||
const handleRemove = (index: number, field: string) => {
|
||||
if (imageFileList.value[field]) {
|
||||
imageFileList.value[field].splice(index, 1);
|
||||
|
||||
// 触发响应式更新
|
||||
imageFileList.value = { ...imageFileList.value };
|
||||
|
||||
// 图片类型始终作为数组存储,删除后更新
|
||||
formData.value[field] = imageFileList.value[field].length > 0
|
||||
? [...imageFileList.value[field]]
|
||||
: [];
|
||||
|
||||
console.log(`[图片删除] 当前图片数量: ${imageFileList.value[field].length}`, imageFileList.value[field]);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const open = () => {
|
||||
visible.value = true;
|
||||
// 初始化表单数据
|
||||
formData.value = {};
|
||||
imageFileList.value = {};
|
||||
|
||||
// 如果有已存在的设置,填充表单
|
||||
if (props.existingSettings && Object.keys(props.existingSettings).length > 0) {
|
||||
Object.keys(props.existingSettings).forEach((key) => {
|
||||
const input = props.flowInputs.find((i) => i.field === key);
|
||||
if (input) {
|
||||
if (input.type === 'picture') {
|
||||
// 确保是数组格式
|
||||
const urls = Array.isArray(props.existingSettings![key])
|
||||
? props.existingSettings![key]
|
||||
: (props.existingSettings![key] ? [props.existingSettings![key]] : []);
|
||||
imageFileList.value[key] = urls.filter(url => url); // 过滤空值
|
||||
formData.value[key] = [...imageFileList.value[key]]; // 始终作为数组
|
||||
} else {
|
||||
formData.value[key] = props.existingSettings![key];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 确定
|
||||
const handleOk = async () => {
|
||||
if (!canSubmit.value) {
|
||||
message.warning('请填写所有必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建最终数据
|
||||
const result: Record<string, any> = {};
|
||||
flowInputs.value.forEach((input) => {
|
||||
const value = formData.value[input.field];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
result[input.field] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// 先保存,再触发事件
|
||||
// 直接通过emit返回设置数据,不需要单独保存
|
||||
// 设置会在发送消息时自动保存到后端
|
||||
emit('ok', result);
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
open,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.settings-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 10px 5px;
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label) {
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-input-number),
|
||||
:deep(.ant-select) {
|
||||
margin: 0 5px;
|
||||
width: calc(100% - 10px) !important;
|
||||
}
|
||||
|
||||
:deep(.ant-upload) {
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.image-upload-container {
|
||||
margin: 0 5px;
|
||||
|
||||
.image-list-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-preview-item {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d9d9d9;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-remove-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .image-remove-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background-color: #fafafa;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
&:hover .upload-text {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<!--image放大封装-->
|
||||
<template>
|
||||
<div class="amplify-image">
|
||||
<div class="img-preview-content" @click="hideImageClick" @mousewheel="handlePicMousewheel">
|
||||
<img :src="imageUrl" ref="imageRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
//图片地址
|
||||
import {onMounted, ref, unref} from 'vue';
|
||||
const props = defineProps(['imageUrl']);
|
||||
const emit = defineEmits(['register', 'hide']);
|
||||
//图片的ref
|
||||
const imageRef = ref();
|
||||
//缩放级别
|
||||
const scale = ref<number>(1);
|
||||
|
||||
/**
|
||||
* 隐藏图片
|
||||
*/
|
||||
function hideImageClick() {
|
||||
scale.value = 1;
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标滑轮滚动
|
||||
* @param event
|
||||
*/
|
||||
function handlePicMousewheel(event) {
|
||||
event.preventDefault();
|
||||
// 判断是放大还是缩小
|
||||
const delta = event.deltaY > 0 ? -1 : 1;
|
||||
const scaleStep = 0.1;
|
||||
// 更新缩放级别
|
||||
scale.value = scale.value + delta * scaleStep
|
||||
imageRef.value.style.transform = `scale(${unref(scale)})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.amplify-image{
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
.img-preview-content{
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
touch-action: none;
|
||||
-webkit-user-drag: none;
|
||||
img{
|
||||
transition: transform 0.3s;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
-webkit-background-size: cover;
|
||||
-moz-background-size: cover;
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useChatStore } from '@/store';
|
||||
|
||||
export function useChat() {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const getChatByUuidAndIndex = (uuid: number, index: number) => {
|
||||
return chatStore.getChatByUuidAndIndex(uuid, index);
|
||||
};
|
||||
|
||||
const addChat = (uuid: number, chat: Chat.Chat) => {
|
||||
chatStore.addChatByUuid(uuid, chat);
|
||||
};
|
||||
|
||||
const updateChat = (uuid: number, index: number, chat: Chat.Chat) => {
|
||||
chatStore.updateChatByUuid(uuid, index, chat);
|
||||
};
|
||||
|
||||
const updateChatSome = (uuid: number, index: number, chat: Partial<Chat.Chat>) => {
|
||||
chatStore.updateChatSomeByUuid(uuid, index, chat);
|
||||
};
|
||||
|
||||
return {
|
||||
addChat,
|
||||
updateChat,
|
||||
updateChatSome,
|
||||
getChatByUuidAndIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
type ScrollElement = HTMLDivElement | null;
|
||||
|
||||
interface ScrollReturn {
|
||||
scrollRef: Ref<ScrollElement>;
|
||||
scrollToBottom: () => Promise<void>;
|
||||
scrollToTop: () => Promise<void>;
|
||||
scrollToBottomIfAtBottom: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useScroll(): ScrollReturn {
|
||||
const scrollRef = ref<ScrollElement>(null);
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
if (scrollRef.value) scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
|
||||
};
|
||||
|
||||
const scrollToTop = async () => {
|
||||
await nextTick();
|
||||
if (scrollRef.value) scrollRef.value.scrollTop = 0;
|
||||
};
|
||||
|
||||
const scrollToBottomIfAtBottom = async () => {
|
||||
await nextTick();
|
||||
if (scrollRef.value) {
|
||||
const threshold = 100; // Threshold, indicating the distance threshold to the bottom of the scroll bar.
|
||||
const distanceToBottom = scrollRef.value.scrollHeight - scrollRef.value.scrollTop - scrollRef.value.clientHeight;
|
||||
if (distanceToBottom <= threshold) scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
scrollToBottomIfAtBottom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { JeecgTag } from './types';
|
||||
import { shallowRef } from 'vue';
|
||||
|
||||
import ToolExecTag from './tool-exec';
|
||||
import JeecgChart from "./jeecg-chart";
|
||||
|
||||
export const jeecgTagMap: Map<string, JeecgTag> = new Map();
|
||||
// 所有 jeecg 标签名称列表
|
||||
export const tagNames: string[] = [];
|
||||
|
||||
// 注册 工具调用 标签
|
||||
useJeecgTag(ToolExecTag);
|
||||
// 注册 图表渲染 标签
|
||||
useJeecgTag(JeecgChart);
|
||||
|
||||
// jeecg 标签统一的 class 名称
|
||||
export const JEECG_TAG_CLASS = 'jeecg-tag';
|
||||
|
||||
/**
|
||||
* 忽略 jeecg 自定义标签的解析
|
||||
* @param md
|
||||
*/
|
||||
export function mdPluginJeecgTag(md: any) {
|
||||
// 保存原始的 html_block 渲染规则
|
||||
const htmlBlockOrigin =
|
||||
md.renderer.rules.html_block ||
|
||||
function (tokens, idx) {
|
||||
return tokens[idx].content;
|
||||
};
|
||||
|
||||
// 覆盖 html_block 渲染规则
|
||||
md.renderer.rules.html_block = function (tokens, idx) {
|
||||
const token = tokens[idx];
|
||||
const content = token.content;
|
||||
|
||||
let isJeecgTag = false;
|
||||
let tagName = '';
|
||||
|
||||
for (const name of tagNames) {
|
||||
// 检查内容是否包含自定义标签的起始或结束标签
|
||||
const startTag = new RegExp(`<${name}(\\s|>)`, 'i');
|
||||
const endTag = new RegExp(`</${name}>`, 'i');
|
||||
if (startTag.test(content) || endTag.test(content)) {
|
||||
isJeecgTag = true;
|
||||
tagName = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// jeecg 自定义标签
|
||||
if (isJeecgTag) {
|
||||
const box = document.createElement('div');
|
||||
box.innerHTML = content;
|
||||
const tag = box.firstElementChild!;
|
||||
tag.classList.add(JEECG_TAG_CLASS);
|
||||
return tag.outerHTML;
|
||||
}
|
||||
|
||||
// 其他 HTML 标签按默认方式渲染
|
||||
return htmlBlockOrigin(tokens, idx);
|
||||
};
|
||||
}
|
||||
|
||||
export function useJeecgTag(tag: JeecgTag) {
|
||||
if (jeecgTagMap.has(tag.name)) {
|
||||
return;
|
||||
}
|
||||
tag.component = shallowRef(tag.component);
|
||||
jeecgTagMap.set(tag.name, tag);
|
||||
tagNames.push(tag.name);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<template>
|
||||
<div class="ai-chat-chart">
|
||||
<div v-if="isError" class="ai-chat-chart__error">{{ errorMessage }}</div>
|
||||
<div v-else-if="!resolvedType || !hasData" class="ai-chat-chart__error">
|
||||
<div v-if="loading"
|
||||
style="color: #999; padding: 12px 8px; border: 1px dashed #eee; margin: 8px 0; border-radius: 4px;">
|
||||
<span>图表渲染中...</span>
|
||||
</div>
|
||||
<span v-else>模型返回的图表渲染格式不正确,请优化提示词或重新尝试。</span>
|
||||
</div>
|
||||
<div v-else class="ai-chat-chart__body">
|
||||
<!-- 折线图 -->
|
||||
<LineMulti v-if="resolvedType === 'line'" v-bind="lineProps"/>
|
||||
<!-- 柱状图 -->
|
||||
<BarMulti v-else-if="resolvedType === 'bar'" v-bind="barProps"/>
|
||||
<!-- 饼图 -->
|
||||
<Pie v-else-if="resolvedType === 'pie'" v-bind="pieProps"/>
|
||||
<!-- 多列柱状图 -->
|
||||
<BarMulti v-else-if="resolvedType === 'multibar'" v-bind="multiBarProps"/>
|
||||
<!-- 多行折线图 -->
|
||||
<LineMulti v-else-if="resolvedType === 'multiline'" v-bind="multiLineProps"/>
|
||||
<!-- 折柱图 -->
|
||||
<BarAndLine v-else-if="resolvedType === 'barline'" v-bind="barLineProps"/>
|
||||
<!-- 面积图 -->
|
||||
<SingleLine v-else-if="resolvedType === 'area'" v-bind="areaLineProps"/>
|
||||
<!-- 雷达图 -->
|
||||
<Radar v-else-if="resolvedType === 'radar'" v-bind="radarProps"/>
|
||||
<!-- 仪表盘 -->
|
||||
<Gauge v-else-if="resolvedType === 'gauge'" v-bind="gaugeProps"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartType } from './types';
|
||||
import { computed, ref, watchEffect } from 'vue';
|
||||
import LineMulti from '/@/components/chart/LineMulti.vue';
|
||||
import BarMulti from '/@/components/chart/BarMulti.vue';
|
||||
import Pie from '/@/components/chart/Pie.vue';
|
||||
import Radar from '/@/components/chart/Radar.vue';
|
||||
import Gauge from '/@/components/chart/Gauge.vue';
|
||||
import BarAndLine from '/@/components/chart/BarAndLine.vue';
|
||||
import SingleLine from '/@/components/chart/SingleLine.vue';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 图表配置字符串,示例:
|
||||
* {"type":"bar","data":[{"x":"数据项1","y":100},{"x":"数据项2","y":80}]}
|
||||
*/
|
||||
data: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 解析失败或类型错误的提示文本。
|
||||
*/
|
||||
const errorMessage = ref<string>('');
|
||||
/**
|
||||
* 是否存在阻止渲染的错误。
|
||||
*/
|
||||
const isError = ref<boolean>(false);
|
||||
|
||||
/**
|
||||
* 将字符串解析为配置对象,失败时记录错误信息。
|
||||
*/
|
||||
const parsedConfig = computed<Recordable>(() => {
|
||||
try {
|
||||
errorMessage.value = '';
|
||||
isError.value = false;
|
||||
return JSON.parse(props.data || '{}');
|
||||
} catch (error) {
|
||||
errorMessage.value = '图表数据解析错误,无法渲染图表。';
|
||||
isError.value = true;
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 支持的图表类型集合。
|
||||
*/
|
||||
// 支持的类型覆盖常见图表,名称与提示词保持宽松映射
|
||||
const supportedTypes: ChartType[] = ['bar', 'line', 'pie', 'radar', 'gauge', 'barline', 'multibar', 'multiline', 'area'];
|
||||
|
||||
/**
|
||||
* 解析得到的图表类型,统一小写后做合法性校验。
|
||||
*/
|
||||
const resolvedType = computed<ChartType>(() => {
|
||||
const rawType = String((parsedConfig.value as any).type || '').toLowerCase();
|
||||
const normalizedType = (rawType || '').replace(/[-_\s]/g, '');
|
||||
const typeAliasMap: Record<string, ChartType> = {
|
||||
bar: 'bar',
|
||||
line: 'line',
|
||||
pie: 'pie',
|
||||
radar: 'radar',
|
||||
gauge: 'gauge',
|
||||
barline: 'barline',
|
||||
barandline: 'barline',
|
||||
linebar: 'barline',
|
||||
multiline: 'multiline',
|
||||
multibar: 'multibar',
|
||||
area: 'area',
|
||||
arealine: 'area',
|
||||
};
|
||||
const mapped = typeAliasMap[normalizedType] || '';
|
||||
if (mapped && supportedTypes.includes(mapped)) {
|
||||
return mapped as ChartType;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
/**
|
||||
* 当类型不被支持时,给出错误提示。
|
||||
*/
|
||||
watchEffect(() => {
|
||||
if (isError.value) {
|
||||
return;
|
||||
}
|
||||
if (resolvedType.value === '') {
|
||||
const typeValue = (parsedConfig.value as any).type;
|
||||
if (typeValue) {
|
||||
errorMessage.value = '当前仅支持 bar、line、pie、radar、gauge、barline、multibar、multiline、area 类型图表。';
|
||||
isError.value = true;
|
||||
}
|
||||
} else {
|
||||
errorMessage.value = '';
|
||||
isError.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 原始数据列表。
|
||||
*/
|
||||
const rawData = computed<unknown>(() => (parsedConfig.value as any).data);
|
||||
|
||||
/**
|
||||
* 判断是否存在可用的数据数组。
|
||||
*/
|
||||
const hasData = computed<boolean>(() => {
|
||||
if (resolvedType.value === 'gauge') {
|
||||
return Boolean(rawData.value);
|
||||
}
|
||||
return Array.isArray(rawData.value) && rawData.value.length > 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* 将原始数据标准化为多序列图表所需的结构。
|
||||
*/
|
||||
const multiSeriesData = computed<Recordable[]>(() => {
|
||||
if (!Array.isArray(rawData.value)) {
|
||||
return [];
|
||||
}
|
||||
return rawData.value.map((item: Recordable) => buildMultiSeriesItem(item));
|
||||
});
|
||||
|
||||
/**
|
||||
* 面积折线与多行折线共享的数据结构,透传 seriesType 控制折线类型。
|
||||
*/
|
||||
const areaSeriesData = computed<Recordable[]>(() => {
|
||||
if (!Array.isArray(rawData.value)) {
|
||||
return [];
|
||||
}
|
||||
return rawData.value.map((item: Recordable) => {
|
||||
return {
|
||||
...buildMultiSeriesItem(item),
|
||||
seriesType: item && item.seriesType ? String(item.seriesType) : 'line',
|
||||
areaStyle: {},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 将原始数据标准化为饼图所需的结构。
|
||||
*/
|
||||
const pieSeriesData = computed<Recordable[]>(() => {
|
||||
if (!Array.isArray(rawData.value)) {
|
||||
return [];
|
||||
}
|
||||
return rawData.value.map((item: Recordable) => {
|
||||
return {
|
||||
name: resolveName(item),
|
||||
value: resolveNumber(item),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 构建多序列图表需要的标准化数据项。
|
||||
*/
|
||||
function buildMultiSeriesItem(item: Recordable): Recordable {
|
||||
const seriesName = resolveSeriesName(item);
|
||||
const name = resolveName(item);
|
||||
const value = resolveNumber(item);
|
||||
return {type: seriesName, name, value};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析系列名称,优先使用 series,其次使用 type,最后回退为“数据”。
|
||||
*/
|
||||
function resolveSeriesName(item: Recordable): string {
|
||||
if (item && item.series !== undefined) {
|
||||
return String(item.series);
|
||||
}
|
||||
if (item && item.type !== undefined) {
|
||||
return String(item.type);
|
||||
}
|
||||
return '数据';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析横轴名称,支持 x 与 name 字段。
|
||||
*/
|
||||
function resolveName(item: Recordable): string {
|
||||
if (item && item.x !== undefined) {
|
||||
return String(item.x);
|
||||
}
|
||||
if (item && item.name !== undefined) {
|
||||
return String(item.name);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数值字段,支持 y 与 value,非数字时回退为 0。
|
||||
*/
|
||||
function resolveNumber(item: Recordable): number {
|
||||
const rawValue = item ? item.y ?? item.value : null;
|
||||
const num = Number(rawValue);
|
||||
if (Number.isFinite(num)) {
|
||||
return num;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析序列类型,折柱图需要区分 bar / line。
|
||||
*/
|
||||
function resolveSeriesType(item: Recordable): string {
|
||||
if (item && item.seriesType !== undefined) {
|
||||
return String(item.seriesType);
|
||||
}
|
||||
return 'bar';
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘数据,允许数组或对象输入,确保返回 name、value。
|
||||
*/
|
||||
const gaugeData = computed(() => {
|
||||
const dataSource = rawData.value as any;
|
||||
if (Array.isArray(dataSource) && dataSource.length > 0) {
|
||||
const item = dataSource[0];
|
||||
return { name: resolveName(item) || '仪表盘', value: resolveNumber(item) };
|
||||
}
|
||||
if (dataSource && typeof dataSource === 'object') {
|
||||
return { name: resolveName(dataSource) || '仪表盘', value: resolveNumber(dataSource as Recordable) };
|
||||
}
|
||||
return { name: '仪表盘', value: 0 };
|
||||
});
|
||||
|
||||
/**
|
||||
* 雷达图数据,支持 max 字段定义指标上限。
|
||||
*/
|
||||
const radarSeriesData = computed<Recordable[]>(() => {
|
||||
if (!Array.isArray(rawData.value)) {
|
||||
return [];
|
||||
}
|
||||
return rawData.value.map((item: Recordable) => {
|
||||
return {
|
||||
type: resolveSeriesName(item),
|
||||
name: resolveName(item),
|
||||
value: resolveNumber(item),
|
||||
max: item && item.max !== undefined ? Number(item.max) : undefined,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 折柱图数据,附带 seriesType 用于区分柱/线。
|
||||
*/
|
||||
const barLineSeriesData = computed<Recordable[]>(() => {
|
||||
if (!Array.isArray(rawData.value)) {
|
||||
return [];
|
||||
}
|
||||
return rawData.value.map((item: Recordable) => {
|
||||
return {
|
||||
...buildMultiSeriesItem(item),
|
||||
seriesType: resolveSeriesType(item),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 折线图的渲染属性。
|
||||
*/
|
||||
const lineProps = computed(() => {
|
||||
return {
|
||||
type: 'line',
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: multiSeriesData.value,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 柱状图的渲染属性。
|
||||
*/
|
||||
const barProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: multiSeriesData.value,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 饼图的渲染属性。
|
||||
*/
|
||||
const pieProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: pieSeriesData.value,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 多列柱状图配置,与 bar 相同但允许区分类型前缀。
|
||||
*/
|
||||
const multiBarProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: multiSeriesData.value,
|
||||
option: parsedConfig.value.option || {},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 多行折线图配置。
|
||||
*/
|
||||
const multiLineProps = computed(() => {
|
||||
return {
|
||||
type: 'line',
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: multiSeriesData.value,
|
||||
option: parsedConfig.value.option || {},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 面积折线图配置,开启面积样式。
|
||||
*/
|
||||
const areaLineProps = computed(() => {
|
||||
return {
|
||||
type: 'line',
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: areaSeriesData.value,
|
||||
option: { ...(parsedConfig.value.option || {}), areaStyle: {} },
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 雷达图配置。
|
||||
*/
|
||||
const radarProps = computed(() => {
|
||||
return {
|
||||
height: '420px',
|
||||
width: '100%',
|
||||
chartData: radarSeriesData.value,
|
||||
option: parsedConfig.value.option || {},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 仪表盘配置。
|
||||
*/
|
||||
const gaugeProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: gaugeData.value,
|
||||
option: parsedConfig.value.option || {},
|
||||
seriesColor: (parsedConfig.value as any).seriesColor || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 折柱图配置,支持自定义颜色。
|
||||
*/
|
||||
const barLineProps = computed(() => {
|
||||
return {
|
||||
height: '360px',
|
||||
width: '100%',
|
||||
chartData: barLineSeriesData.value,
|
||||
customColor: (parsedConfig.value as any).colors || [],
|
||||
option: parsedConfig.value.option || {},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
.ai-chat-chart {
|
||||
width: 100%;
|
||||
min-width: 360px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.ai-chat-chart__body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ai-chat-chart__error {
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { JeecgTag } from '../types';
|
||||
|
||||
import ChartRender from './ChartRender.vue';
|
||||
|
||||
const Tag: JeecgTag = {
|
||||
name: 'jeecg-chart',
|
||||
component: ChartRender,
|
||||
};
|
||||
|
||||
export default Tag;
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 支持的图表类型
|
||||
*/
|
||||
export type ChartType = 'bar' | 'line' | 'pie' | 'radar' | 'gauge' | 'barline' | 'multibar' | 'multiline' | 'area' | '';
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<div class="tool-exec-wrapper">
|
||||
<div v-if="!toolRecord" class="tool-exec-empty">暂无工具调用结果</div>
|
||||
<div v-else-if="toolRecord.loading" class="tool-exec-loading">
|
||||
<LoadingOutlined spin/>
|
||||
<span style="margin-left: 8px;">正在运行 {{ titleText }}</span>
|
||||
</div>
|
||||
<div v-else class="tool-exec-list">
|
||||
<div class="tool-exec-card">
|
||||
<div class="card-header" @click="toggleCard()">
|
||||
<div class="header-left">
|
||||
<span class="status-icon" :class="`status-${resolvedStatus}`"></span>
|
||||
<div class="title-block">
|
||||
<div class="title-text">
|
||||
<span class="status-text">{{ statusLabel }}</span>
|
||||
<span>{{ titleText }}</span>
|
||||
</div>
|
||||
<div class="subtitle-text" v-if="toolRecord.subtitle">
|
||||
<span>{{ toolRecord.subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- <span class="collapse-text">{{ expanded ? '收起' : '展开' }}</span>-->
|
||||
<span class="collapse-icon" :class="{ opened: expanded }">
|
||||
<DownOutlined/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expanded" class="card-body">
|
||||
<div class="section">
|
||||
<div class="section-title">输入</div>
|
||||
<pre class="section-content">{{ formattedInput }}</pre>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">输出</div>
|
||||
<pre class="section-content">{{ formattedOutput }}</pre>
|
||||
</div>
|
||||
<div v-if="toolRecord.errorMessage" class="section">
|
||||
<div class="section-title">错误信息</div>
|
||||
<pre class="section-content">{{ formattedError }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { DownOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
/**
|
||||
* 工具调用结果记录类型
|
||||
*/
|
||||
type ToolExecStatus = 'success' | 'running' | 'error';
|
||||
|
||||
/**
|
||||
* 工具调用展示记录
|
||||
*/
|
||||
interface ToolExecRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
mcpName?: string;
|
||||
input: string;
|
||||
output: string;
|
||||
loading: boolean;
|
||||
hasError: boolean;
|
||||
|
||||
subtitle?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 解析 props.data 为单条记录
|
||||
*/
|
||||
const parsedRecord = computed<ToolExecRecord | null>(() => {
|
||||
try {
|
||||
const value = JSON.parse(props.data);
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as ToolExecRecord;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 归一化后的单条记录,补齐常用字段
|
||||
*/
|
||||
const toolRecord = computed<ToolExecRecord | null>(() => {
|
||||
const item = parsedRecord.value;
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
status: status,
|
||||
toolName: item.name,
|
||||
input: item.input ?? (item as any).request ?? (item as any).payload ?? (item as any).args,
|
||||
output: item.output ?? (item as any).response ?? (item as any).result,
|
||||
errorMessage: '',
|
||||
} as ToolExecRecord;
|
||||
});
|
||||
|
||||
/**
|
||||
* 单卡片展开状态
|
||||
*/
|
||||
const expanded = ref<boolean>(false);
|
||||
|
||||
/**
|
||||
* 状态文本映射
|
||||
*/
|
||||
const statusLabelMap: Record<ToolExecStatus, string> = {
|
||||
success: '已运行',
|
||||
running: '执行中',
|
||||
error: '执行失败',
|
||||
};
|
||||
|
||||
/**
|
||||
* 已解析状态
|
||||
*/
|
||||
const resolvedStatus = computed<ToolExecStatus>(() => {
|
||||
const record = toolRecord.value;
|
||||
if (record) {
|
||||
if (record.loading === true) {
|
||||
return 'running';
|
||||
}
|
||||
if (record.hasError === true) {
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
return 'success';
|
||||
});
|
||||
|
||||
/**
|
||||
* 状态标签文案
|
||||
*/
|
||||
const statusLabel = computed<string>(() => {
|
||||
const current = resolvedStatus.value;
|
||||
return statusLabelMap[current];
|
||||
});
|
||||
|
||||
/**
|
||||
* 卡片标题
|
||||
*/
|
||||
const titleText = computed<string>(() => {
|
||||
if (toolRecord.value) {
|
||||
const parts: string[] = [];
|
||||
if (toolRecord.value.name) {
|
||||
parts.push(toolRecord.value.name as string);
|
||||
}
|
||||
if (toolRecord.value.mcpName) {
|
||||
parts.push(toolRecord.value.mcpName as string);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
return parts.join(' - ');
|
||||
}
|
||||
}
|
||||
return '工具调用';
|
||||
});
|
||||
|
||||
/**
|
||||
* 输入、输出、错误信息格式化
|
||||
*/
|
||||
const formattedInput = computed<string>(() => {
|
||||
return formatData(toolRecord.value ? toolRecord.value.input : '');
|
||||
});
|
||||
|
||||
const formattedOutput = computed<string>(() => {
|
||||
return formatData(toolRecord.value ? toolRecord.value.output : '');
|
||||
});
|
||||
|
||||
const formattedError = computed<string>(() => {
|
||||
return formatData(toolRecord.value ? toolRecord.value.errorMessage : '');
|
||||
});
|
||||
|
||||
/**
|
||||
* 切换卡片展开状态
|
||||
*/
|
||||
function toggleCard(): void {
|
||||
const next = !expanded.value;
|
||||
expanded.value = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化输入或输出数据,保证预格式化可读
|
||||
* @param value 输入或输出值
|
||||
*/
|
||||
function formatData(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch (error) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tool-exec-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tool-exec-loading,
|
||||
.tool-exec-empty {
|
||||
padding: 12px 16px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.tool-exec-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-exec-card {
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 10px;
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card-header:hover {
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #c8c9cc;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
border-color: #52c41a;
|
||||
background-color: #f6ffed;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
border-color: #1677ff;
|
||||
background-color: #e6f4ff;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
border-color: #ff4d4f;
|
||||
background-color: #fff1f0;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-weight: 600;
|
||||
|
||||
.status-text {
|
||||
margin-right: 6px;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.collapse-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.opened {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0 14px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background-color: #f8f8f8;
|
||||
border: 1px solid #e5e6eb;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.section-content:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { JeecgTag } from '../types';
|
||||
|
||||
import JeecgToolExec from './JeecgToolExec.vue';
|
||||
|
||||
const Tag: JeecgTag = {
|
||||
name: 'jeecg-tool-exec',
|
||||
component: JeecgToolExec,
|
||||
};
|
||||
|
||||
export default Tag;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component } from 'vue';
|
||||
|
||||
/**
|
||||
* JeecgTag类型
|
||||
*/
|
||||
export type JeecgTag = {
|
||||
name: string;
|
||||
component: Component;
|
||||
};
|
||||
188
jeecgboot-vue3/src/views/super/airag/aiapp/chat/js/chat.js
Normal file
188
jeecgboot-vue3/src/views/super/airag/aiapp/chat/js/chat.js
Normal file
@@ -0,0 +1,188 @@
|
||||
// iframe-widget.js
|
||||
(function () {
|
||||
let widgetInstance = null;
|
||||
const defaultConfig = {
|
||||
// 支持'top-left'左上, 'top-right'右上, 'bottom-left'左下, 'bottom-right'右下
|
||||
iconPosition: 'bottom-right',
|
||||
//图标的大小
|
||||
iconSize: '45px',
|
||||
//图标的颜色
|
||||
iconColor: '#155eef',
|
||||
//必填不允许修改
|
||||
appId: '',
|
||||
//聊天弹窗的宽度
|
||||
chatWidth: '800px',
|
||||
//聊天弹窗的高度
|
||||
chatHeight: '700px',
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建ai图标
|
||||
* @param config
|
||||
*/
|
||||
function createAiChat(config) {
|
||||
// 单例模式,确保只存在一个实例
|
||||
if (widgetInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并配置
|
||||
const finalConfig = { ...defaultConfig, ...config };
|
||||
|
||||
if (!finalConfig.appId) {
|
||||
console.error('appId为空!');
|
||||
return;
|
||||
}
|
||||
let body = document.body;
|
||||
body.style.margin = "0";
|
||||
// 创建容器
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
z-index: 998;
|
||||
${getPositionStyles(finalConfig.iconPosition)}
|
||||
cursor: pointer;
|
||||
`;
|
||||
// 创建图标
|
||||
const icon = document.createElement('div');
|
||||
icon.style.cssText = `
|
||||
width: ${finalConfig.iconSize};
|
||||
height: ${finalConfig.iconSize};
|
||||
background-color: ${finalConfig.iconColor};
|
||||
border-radius: 50%;
|
||||
box-shadow: #cccccc 0 4px 8px 0;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
icon.innerHTML =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" viewBox="0 0 1024 1024" class="iconify iconify--ant-design"><path fill="currentColor" d="M573 421c-23.1 0-41 17.9-41 40s17.9 40 41 40c21.1 0 39-17.9 39-40s-17.9-40-39-40m-280 0c-23.1 0-41 17.9-41 40s17.9 40 41 40c21.1 0 39-17.9 39-40s-17.9-40-39-40"></path><path fill="currentColor" d="M894 345c-48.1-66-115.3-110.1-189-130v.1c-17.1-19-36.4-36.5-58-52.1c-163.7-119-393.5-82.7-513 81c-96.3 133-92.2 311.9 6 439l.8 132.6c0 3.2.5 6.4 1.5 9.4c5.3 16.9 23.3 26.2 40.1 20.9L309 806c33.5 11.9 68.1 18.7 102.5 20.6l-.5.4c89.1 64.9 205.9 84.4 313 49l127.1 41.4c3.2 1 6.5 1.6 9.9 1.6c17.7 0 32-14.3 32-32V753c88.1-119.6 90.4-284.9 1-408M323 735l-12-5l-99 31l-1-104l-8-9c-84.6-103.2-90.2-251.9-11-361c96.4-132.2 281.2-161.4 413-66c132.2 96.1 161.5 280.6 66 412c-80.1 109.9-223.5 150.5-348 102m505-17l-8 10l1 104l-98-33l-12 5c-56 20.8-115.7 22.5-171 7l-.2-.1C613.7 788.2 680.7 742.2 729 676c76.4-105.3 88.8-237.6 44.4-350.4l.6.4c23 16.5 44.1 37.1 62 62c72.6 99.6 68.5 235.2-8 330"></path><path fill="currentColor" d="M433 421c-23.1 0-41 17.9-41 40s17.9 40 41 40c21.1 0 39-17.9 39-40s-17.9-40-39-40"></path></svg>';
|
||||
|
||||
// 创建iframe容器
|
||||
const iframeContainer = document.createElement('div');
|
||||
let right = finalConfig.chatWidth === '100%' ? '0' : '10px';
|
||||
let bottom = finalConfig.chatHeight === '100%' ? '0' : '10px';
|
||||
let chatWidth = finalConfig.chatWidth;
|
||||
let chatHeight = finalConfig.chatHeight;
|
||||
if(isMobileDevice()){
|
||||
chatWidth = "100%";
|
||||
chatHeight = "100%";
|
||||
right = '0';
|
||||
bottom = '0';
|
||||
}
|
||||
iframeContainer.style.cssText = `
|
||||
position: fixed;
|
||||
right: ${right};
|
||||
bottom: ${bottom};
|
||||
width: ${chatWidth} !important;
|
||||
height: ${chatHeight} !important;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 20px #cccccc;
|
||||
display: none;
|
||||
z-index: 10000;
|
||||
`;
|
||||
|
||||
// 创建iframe
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
iframe.id = 'ai-app-chat-document';
|
||||
//update-begin---author:wangshuai---date:2025-04-25---for:【QQYUN-12159】【AI 广告位】让需要自建AI知识库的用户知道如何通过敲敲云搭建自己的AI知识库---
|
||||
iframe.src = getIframeSrc(finalConfig) + '/ai/app/chat/' + finalConfig.appId + "?source=chatJs";
|
||||
//update-end---author:wangshuai---date:2025-04-25---for:【QQYUN-12159】【AI 广告位】让需要自建AI知识库的用户知道如何通过敲敲云搭建自己的AI知识库---
|
||||
let iconRight = finalConfig.chatWidth === '100%'?'0':'-6px';
|
||||
let iconTop = finalConfig.chatWidth === '100%'?'0':'-9px';
|
||||
if(isMobileDevice()){
|
||||
iconRight = '2px';
|
||||
iconTop = '2px';
|
||||
}
|
||||
// 创建关闭按钮
|
||||
const closeBtn = document.createElement('div');
|
||||
closeBtn.innerHTML =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 1024 1024" class="iconify iconify--ant-design"><path fill="currentColor" fill-rule="evenodd" d="M799.855 166.312c.023.007.043.018.084.059l57.69 57.69c.041.041.052.06.059.084a.1.1 0 0 1 0 .069c-.007.023-.018.042-.059.083L569.926 512l287.703 287.703c.041.04.052.06.059.083a.12.12 0 0 1 0 .07c-.007.022-.018.042-.059.083l-57.69 57.69c-.041.041-.06.052-.084.059a.1.1 0 0 1-.069 0c-.023-.007-.042-.018-.083-.059L512 569.926L224.297 857.629c-.04.041-.06.052-.083.059a.12.12 0 0 1-.07 0c-.022-.007-.042-.018-.083-.059l-57.69-57.69c-.041-.041-.052-.06-.059-.084a.1.1 0 0 1 0-.069c.007-.023.018-.042.059-.083L454.073 512L166.371 224.297c-.041-.04-.052-.06-.059-.083a.12.12 0 0 1 0-.07c.007-.022.018-.042.059-.083l57.69-57.69c.041-.041.06-.052.084-.059a.1.1 0 0 1 .069 0c.023.007.042.018.083.059L512 454.073l287.703-287.702c.04-.041.06-.052.083-.059a.12.12 0 0 1 .07 0Z"></path></svg>';
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute;
|
||||
margin-top: ${iconTop};
|
||||
right: ${iconRight};
|
||||
cursor: pointer;
|
||||
background: white;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px #cccccc;
|
||||
`;
|
||||
|
||||
// 组装元素
|
||||
iframeContainer.appendChild(closeBtn);
|
||||
iframeContainer.appendChild(iframe);
|
||||
document.body.appendChild(iframeContainer);
|
||||
container.appendChild(icon);
|
||||
document.body.appendChild(container);
|
||||
|
||||
// 事件监听
|
||||
icon.addEventListener('click', () => {
|
||||
iframeContainer.style.display = 'block';
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', () => {
|
||||
iframeContainer.style.display = 'none';
|
||||
});
|
||||
|
||||
// 保存实例引用
|
||||
widgetInstance = {
|
||||
remove: () => {
|
||||
container.remove();
|
||||
iframeContainer.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取位置信息
|
||||
*
|
||||
* @param position
|
||||
* @returns {*|string}
|
||||
*/
|
||||
function getPositionStyles(position) {
|
||||
const positions = {
|
||||
'top-left': 'top: 20px; left: 20px;',
|
||||
'top-right': 'top: 20px; right: 20px;',
|
||||
'bottom-left': 'bottom: 20px; left: 20px;',
|
||||
'bottom-right': 'bottom: 20px; right: 20px;',
|
||||
};
|
||||
return positions[position] || positions['bottom-right'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取src地址
|
||||
*/
|
||||
function getIframeSrc(finalConfig) {
|
||||
const specificScript = document.getElementById("e7e007dd52f67fe36365eff636bbffbd");
|
||||
if (specificScript) {
|
||||
return specificScript.src.substring(0, specificScript.src.indexOf('/', specificScript.src.indexOf('://') + 3));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为手机
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMobileDevice() {
|
||||
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
// 暴露全局方法
|
||||
window.createAiChat = createAiChat;
|
||||
})();
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
type ScrollElement = HTMLDivElement | null;
|
||||
|
||||
interface ScrollReturn {
|
||||
scrollRef: Ref<ScrollElement>;
|
||||
scrollToBottom: () => Promise<void>;
|
||||
scrollToTop: () => Promise<void>;
|
||||
scrollToBottomIfAtBottom: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useScroll(): ScrollReturn {
|
||||
const scrollRef = ref<ScrollElement>(null);
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
if (scrollRef.value) scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
|
||||
};
|
||||
|
||||
const scrollToTop = async () => {
|
||||
await nextTick();
|
||||
if (scrollRef.value) scrollRef.value.scrollTop = 0;
|
||||
};
|
||||
|
||||
const scrollToBottomIfAtBottom = async () => {
|
||||
await nextTick();
|
||||
if (scrollRef.value) {
|
||||
const threshold = 100; // Threshold, indicating the distance threshold to the bottom of the scroll bar.
|
||||
const distanceToBottom = scrollRef.value.scrollHeight - scrollRef.value.scrollTop - scrollRef.value.clientHeight;
|
||||
if (distanceToBottom <= threshold) scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
scrollToBottomIfAtBottom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<!-- 应用门户 -->
|
||||
<template>
|
||||
<div ref="portalRef" class="portal-container" :style="portalContainerStyle">
|
||||
<div class="leftArea" :class="[expand ? 'expand' : 'shrink']">
|
||||
<div class="content">
|
||||
<LeftPortalSession ref="leftPortalRef" @app-click="handleAppClick" @task-click="handleTaskClick"></LeftPortalSession>
|
||||
</div>
|
||||
<div class="toggle-btn" @click="handleToggle">
|
||||
<span class="icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.64645 3.14645C5.45118 3.34171 5.45118 3.65829 5.64645 3.85355L9.79289 8L5.64645 12.1464C5.45118 12.3417 5.45118 12.6583 5.64645 12.8536C5.84171 13.0488 6.15829 13.0488 6.35355 12.8536L10.8536 8.35355C11.0488 8.15829 11.0488 7.84171 10.8536 7.64645L6.35355 3.14645C6.15829 2.95118 5.84171 2.95118 5.64645 3.14645Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rightArea" :class="[expand ? 'expand' : 'shrink']">
|
||||
<chat
|
||||
:key="uuid"
|
||||
url="/airag/chat/send"
|
||||
v-if="uuid"
|
||||
:uuid="uuid"
|
||||
:historyData="historyData"
|
||||
type="view"
|
||||
:formState="appData"
|
||||
:prologue="appData?.prologue"
|
||||
:presetQuestion="appData?.presetQuestion"
|
||||
@reload-message-title="reloadMessageTitle"
|
||||
:chatTitle="chatTitle"
|
||||
:conversationSettings="getCurrentSettings"
|
||||
@edit-settings="handleEditSettings"
|
||||
sessionType="portal"
|
||||
ref="chatRef"
|
||||
></chat>
|
||||
<div v-if="showWelcome" class="emptyArea">
|
||||
<div class="welcome-text">{{ welcomeText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import LeftPortalSession from './LeftPortalSession.vue';
|
||||
import chat from '/@/views/super/airag/aiapp/chat/chat.vue';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const portalContainerStyle = ref<any>(null);
|
||||
//应用门户的ref
|
||||
const portalRef = ref();
|
||||
//左侧列表是否展开
|
||||
const expand = ref<any>(true);
|
||||
//左侧列表展开关闭事件
|
||||
const handleToggle = () => {
|
||||
expand.value = !expand.value;
|
||||
};
|
||||
//随机id
|
||||
const uuid = ref<string>('');
|
||||
//历史记录
|
||||
const historyData = ref<any>();
|
||||
//应用数据
|
||||
const appData = ref<any>();
|
||||
//聊天标题
|
||||
const chatTitle = ref<any>();
|
||||
//当前会话的设置
|
||||
const conversationSettings = ref<Record<string, Record<string, any>>>({});
|
||||
// 获取当前会话的设置
|
||||
const getCurrentSettings = computed(() => {
|
||||
return conversationSettings.value[uuid.value] || {};
|
||||
});
|
||||
//对话设置弹窗ref
|
||||
const settingsModalRef = ref();
|
||||
//左侧会话的ref
|
||||
const leftPortalRef = ref();
|
||||
// 欢迎语(取当前登录用户姓名或用户名)
|
||||
const userStore = useUserStore();
|
||||
const welcomeName = computed(() => userStore.getUserInfo?.realname || userStore.getUserInfo?.username || '');
|
||||
const welcomeText = computed(() => (welcomeName.value ? `你好,${welcomeName.value}。准备好开始了吗?` : '你好,准备好开始了吗?'));
|
||||
const showWelcome = ref<boolean>(false);
|
||||
|
||||
// 指定长度和基数
|
||||
const getUuid = (len = 10, radix = 16) => {
|
||||
var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
|
||||
var uuid: any = [],
|
||||
i;
|
||||
radix = radix || chars.length;
|
||||
|
||||
if (len) {
|
||||
for (i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)];
|
||||
} else {
|
||||
var r;
|
||||
uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
|
||||
uuid[14] = '4';
|
||||
for (i = 0; i < 36; i++) {
|
||||
if (!uuid[i]) {
|
||||
r = 0 | (Math.random() * 16);
|
||||
uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r];
|
||||
}
|
||||
}
|
||||
}
|
||||
return uuid.join('');
|
||||
};
|
||||
|
||||
/**
|
||||
* 重新加载标题
|
||||
* @param title
|
||||
*/
|
||||
function reloadMessageTitle(title) {
|
||||
showWelcome.value = false;
|
||||
leftPortalRef.value.addSession(title, uuid.value);
|
||||
}
|
||||
|
||||
// 编辑对话设置
|
||||
function handleEditSettings() {
|
||||
if (settingsModalRef.value) {
|
||||
settingsModalRef.value.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用数据点击返回事件
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleAppClick(value) {
|
||||
//每次点击都是一个新的会话
|
||||
uuid.value = getUuid(32);
|
||||
appData.value = value;
|
||||
chatTitle.value = appData.value.name;
|
||||
showWelcome.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回话点击事件
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
function handleTaskClick(id, title) {
|
||||
showWelcome.value = false;
|
||||
uuid.value = id;
|
||||
chatTitle.value = title;
|
||||
//根据选中的id查询聊天内容
|
||||
let params = { conversationId: id, sessionType: 'portal' };
|
||||
//根据id获取历史记录
|
||||
defHttp.get({ url: '/airag/chat/messages', params }, { isTransformResponse: false }).then((res) => {
|
||||
if (res.success) {
|
||||
// 处理新的返回格式(包含messages和flowInputs)
|
||||
if (res.result && res.result.messages) {
|
||||
historyData.value = res.result.messages;
|
||||
if (res.result?.appData) {
|
||||
appData.value = res.result.appData;
|
||||
} else {
|
||||
appData.value = null;
|
||||
}
|
||||
// 加载已保存的设置
|
||||
if (res.result.flowInputs) {
|
||||
conversationSettings.value[id] = res.result.flowInputs;
|
||||
}
|
||||
} else if (Array.isArray(res.result)) {
|
||||
// 兼容旧格式
|
||||
historyData.value = res.result;
|
||||
} else {
|
||||
historyData.value = [];
|
||||
}
|
||||
} else {
|
||||
historyData.value = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 当 uuid 变化时,清空右侧聊天的会话数据并初始化当前设置,确保开启全新会话
|
||||
watch(
|
||||
() => uuid.value,
|
||||
(newVal) => {
|
||||
if (!newVal) return;
|
||||
// 清空标题与历史记录、防止沿用上一会话数据
|
||||
historyData.value = [];
|
||||
// 初始化当前会话设置容器
|
||||
conversationSettings.value[newVal] = conversationSettings.value[newVal] || {};
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => portalRef.value,
|
||||
() => {
|
||||
if (portalRef.value.offsetHeight) {
|
||||
portalRef.value = { height: `${portalRef.value.offsetHeight} px` };
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@width: 260px;
|
||||
.portal-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
background: white;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
z-index: 800;
|
||||
border: 1px solid #eeeeee;
|
||||
}
|
||||
.leftArea {
|
||||
width: @width;
|
||||
transition: 0.3s left;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.shrink {
|
||||
left: -@width;
|
||||
|
||||
.toggle-btn {
|
||||
.icon {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
transition:
|
||||
color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
right 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: rgb(51, 54, 57);
|
||||
border: 1px solid rgb(239, 239, 245);
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 4px 0px #e7e9ef;
|
||||
transform: translateX(50%) translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: rotate(180deg);
|
||||
font-size: 18px;
|
||||
height: 18px;
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rightArea {
|
||||
margin-left: @width;
|
||||
transition: 0.3s margin-left;
|
||||
|
||||
&.shrink {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.emptyArea {
|
||||
position: absolute;
|
||||
top: 45%;
|
||||
left: 45%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
.emptyArea .welcome-text {
|
||||
font-size: 32px;
|
||||
color: #3d4353;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,478 @@
|
||||
<!-- 左侧应用门户会话设置 -->
|
||||
<template>
|
||||
<div class="space-page">
|
||||
<div class="header">
|
||||
<img class="header-image" :src="loginLogo" />
|
||||
<div class="header-name"> JEECG </div>
|
||||
</div>
|
||||
<div class="new-session" @click="handleNewSession">
|
||||
<div class="left-box">
|
||||
<div class="app-icon">
|
||||
<Icon icon="ant-design:edit-outlined" size="14"></Icon>
|
||||
</div>
|
||||
<div class="app-name">新对话</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-scroll">
|
||||
<div class="section">
|
||||
<template v-for="app in apps">
|
||||
<div class="app-item" :class="activeKey === app.id ? 'active' : ''" @click="handleAppClick(app)">
|
||||
<div class="app-icon">
|
||||
<img :src="getAiImg(app)" />
|
||||
</div>
|
||||
<div class="app-name" :title="app.name">{{ app.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">历史对话</div>
|
||||
<div v-if="tasks.length" class="task-list">
|
||||
<div v-for="task in tasks" :key="task.id" class="task-item" :class="activeKey === task.id ? 'active' : ''" @click="handleTaskClick(task)">
|
||||
<div class="task-title" :title="task.title" v-if="!task.isEdit">{{ task.title }}</div>
|
||||
<a-input
|
||||
class="title"
|
||||
ref="inputRef"
|
||||
v-if="task.isEdit"
|
||||
:defaultValue="task.title"
|
||||
placeholder="请输入标题"
|
||||
@change="handleInputChange"
|
||||
@keyup.enter="inputBlur(task)"
|
||||
/>
|
||||
<div class="icon-edit">
|
||||
<a-space>
|
||||
<span class="icon edit" @click.prevent.stop="handleEdit(task)" v-if="!task.isEdit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" role="img" class="iconify iconify--ri" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6.414 15.89L16.556 5.748l-1.414-1.414L5 14.476v1.414zm.829 2H3v-4.243L14.435 2.212a1 1 0 0 1 1.414 0l2.829 2.829a1 1 0 0 1 0 1.414zM3 19.89h18v2H3z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="icon del">
|
||||
<a-popconfirm
|
||||
:overlayStyle="{ 'z-index': 9999 }"
|
||||
title="确定删除此记录?"
|
||||
placement="bottom"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm.prevent.stop="handleDel(task)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" role="img" class="iconify iconify--ri" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17 6h5v2h-2v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8H2V6h5V3a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1zm1 2H6v12h12zm-9 3h2v6H9zm4 0h2v6h-2zM9 4v2h6V4z"
|
||||
></path>
|
||||
</svg>
|
||||
</a-popconfirm>
|
||||
</span>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无历史对话" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { getFileAccessHttpUrl } from '@/utils/common/compUtils';
|
||||
import defaultImg from '@/views/super/airag/aiapp/img/ailogo.png';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import loginLogo from '/@/assets/images/logo.png';
|
||||
|
||||
const emit = defineEmits(['register', 'app-click', 'task-click', 'new-session-click']);
|
||||
|
||||
const defaultApp = ref({
|
||||
id: '1999373661846880258',
|
||||
name: '聊天助手',
|
||||
});
|
||||
//应用列表
|
||||
const apps = ref([
|
||||
{
|
||||
id: '1998717610730352641',
|
||||
name: '帮我写作',
|
||||
icon: 'https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/helpWriting_1765520898059.png',
|
||||
prologue: '请输入\n出发地:\n目的地:\n人数:',
|
||||
},
|
||||
{
|
||||
id: '1996471445272088578',
|
||||
name: '图像识别',
|
||||
icon: 'https://jeecgdev.oss-cn-beijing.aliyuncs.com/temp/1dataOCR_1743065089791.png',
|
||||
prologue: '上传一张图片,我来为你识别图片的内容',
|
||||
},
|
||||
{
|
||||
id: '1902262577996546050',
|
||||
name: '看图说话',
|
||||
icon: 'https://jeecgdev.oss-cn-beijing.aliyuncs.com/temp/工具-图片解析_1743065064801.png',
|
||||
prologue: '上传一张图片,我来为你讲述图片中的故事',
|
||||
},
|
||||
{
|
||||
id: '2008448202536456193',
|
||||
name: 'Chat2BI',
|
||||
icon: 'https://minio.jeecg.com/otatest/chatShow_1769395642452.png',
|
||||
prologue: '你好,我是Chat2BI 图表生成智能体。',
|
||||
flowId: '2008379264947519489',
|
||||
type: 'chatFLow',
|
||||
presetQuestion: '[{"key":1,"descr":"请统计系统用户的性别分布比例,并以饼状图和列表表格展示。","update":true}]'
|
||||
},
|
||||
{
|
||||
id: '2008090512835629057',
|
||||
name: 'AI绘画',
|
||||
icon: 'https://minio.jeecg.com/otatest/AiWrite_1769395779558.png',
|
||||
prologue: '你好,我是 AI绘图智能体。',
|
||||
presetQuestion: '[{"key":1,"descr":"请生成一张具有日本风格的动漫成年女孩。","update":true}, {"key":2,"descr":"请生成一幅中国神话故事中,手持武器的哪吒形象。","update":true}]',
|
||||
metadata:"{\"izDraw\":\"1\"}"
|
||||
},
|
||||
]);
|
||||
|
||||
//应用数据
|
||||
const appData = ref<any>({});
|
||||
|
||||
//会话
|
||||
const tasks = ref<any>([]);
|
||||
|
||||
/**
|
||||
* 点击的key
|
||||
*/
|
||||
const activeKey = ref<string>('');
|
||||
|
||||
/**
|
||||
* 获取图片
|
||||
*/
|
||||
const getAiImg = (app) => {
|
||||
return getFileAccessHttpUrl(app?.icon) || defaultImg;
|
||||
};
|
||||
let inputValue: string = '';
|
||||
const handleInputChange = (e) => {
|
||||
inputValue = e.target.value.trim();
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (item) => {
|
||||
item.isEdit = true;
|
||||
inputValue = item.title;
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用点击事件
|
||||
*
|
||||
* @param app
|
||||
*/
|
||||
function handleAppClick(app) {
|
||||
activeKey.value = app.id;
|
||||
appData.value = app;
|
||||
emit('app-click', app);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加会话
|
||||
*/
|
||||
function addSession(title, id) {
|
||||
activeKey.value = id;
|
||||
if (tasks.value?.length > 0) {
|
||||
let findIndex = tasks.value.findIndex((item) => item.id === id);
|
||||
if (findIndex >= 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
tasks.value.unshift({ id: id, title: title });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话
|
||||
*/
|
||||
async function getSessionList() {
|
||||
const res = await defHttp.get(
|
||||
{
|
||||
url: '/airag/chat/getConversationsByType',
|
||||
params: { sessionType: 'portal' },
|
||||
},
|
||||
{ isTransformResponse: false }
|
||||
);
|
||||
if (res && res.success) {
|
||||
tasks.value = res.result;
|
||||
} else {
|
||||
tasks.value = [];
|
||||
}
|
||||
return tasks.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话点击事件
|
||||
*
|
||||
* @param task
|
||||
*/
|
||||
function handleTaskClick(task) {
|
||||
if (task.id === activeKey.value) {
|
||||
return;
|
||||
}
|
||||
activeKey.value = task.id;
|
||||
emit('task-click', task.id, task.title);
|
||||
}
|
||||
|
||||
// 失去焦点
|
||||
function inputBlur(item) {
|
||||
item.isEdit = false;
|
||||
item.title = inputValue;
|
||||
defHttp.put(
|
||||
{
|
||||
url: '/airag/chat/conversation/update/title',
|
||||
params: { id: item.id, title: inputValue, sessionType: 'portal' },
|
||||
},
|
||||
{ joinParamsToUrl: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
* @param data
|
||||
*/
|
||||
function handleDel(data) {
|
||||
defHttp
|
||||
.delete(
|
||||
{
|
||||
url: '/airag/chat/conversation/' + data.id + '/portal',
|
||||
},
|
||||
{ isTransformResponse: false }
|
||||
)
|
||||
.then(() => {
|
||||
getSessionList();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新对话
|
||||
*/
|
||||
function handleNewSession() {
|
||||
activeKey.value = '';
|
||||
emit('app-click', defaultApp.value);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
activeKey.value = '';
|
||||
const list = await getSessionList();
|
||||
if (list && list.length > 0) {
|
||||
const first = list[0];
|
||||
activeKey.value = first.id;
|
||||
emit('task-click', first.id, first.title);
|
||||
} else {
|
||||
emit('app-click', defaultApp.value);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
addSession,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.space-page {
|
||||
padding: 12px 16px;
|
||||
height: 100%;
|
||||
background: #fbfcff; // 左侧浅色背景,区分右侧
|
||||
border-right: 1px solid #eef2f6; // 与右侧的分隔线
|
||||
display: flex; // 纵向布局,顶部固定,下面滚动
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.space-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.space-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.app-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
margin-left: 10px;
|
||||
font-size: 14px;
|
||||
color: #1f2329;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.new-session {
|
||||
margin-top: 10px;
|
||||
align-items: center;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
margin-right: 1px;
|
||||
overflow: visible;
|
||||
padding: 6px 10px !important;
|
||||
position: relative;
|
||||
transition: all 0.15s ease-in-out;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
border: 1px solid #e0ecff;
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.new-session:hover {
|
||||
background: #eaf2ff;
|
||||
border-color: #d4e5ff;
|
||||
}
|
||||
|
||||
.new-session.active {
|
||||
background: #eaf2ff;
|
||||
border-color: #cfe0ff;
|
||||
}
|
||||
|
||||
.new-session .left-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.new-session .app-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #eaf2ff;
|
||||
color: #3761eb;
|
||||
}
|
||||
|
||||
.new-session .app-name {
|
||||
margin-left: 10px;
|
||||
font-size: 14px;
|
||||
color: #3761eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
align-items: center;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
margin-right: 1px;
|
||||
overflow: visible;
|
||||
padding: 6px 10px !important;
|
||||
position: relative;
|
||||
transition: all 0.15s ease-in-out;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 12px;
|
||||
|
||||
.edit,
|
||||
.del {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 14px;
|
||||
color: #1f2329;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 12px;
|
||||
.edit {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.edit,
|
||||
.del {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-edit {
|
||||
display: flex;
|
||||
}
|
||||
.session-scroll {
|
||||
flex: 1; // 占满剩余空间
|
||||
min-height: 0; // 允许子元素在容器内正确滚动
|
||||
overflow-y: auto; // 仅列表区域滚动
|
||||
margin-bottom: 20px;
|
||||
|
||||
// 自定义滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #d9dfe7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-left: 0;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
background-color: #fff;
|
||||
.header-image {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.header-name {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-size: 16px;
|
||||
align-self: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="presetQuestion-wrap">
|
||||
<!-- <svg
|
||||
v-if="btnShow"
|
||||
class="leftBtn"
|
||||
:class="leftBtnStatus"
|
||||
t="1710296339017"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="5070"
|
||||
@click="onScroll('prev')"
|
||||
>
|
||||
<path
|
||||
d="M970.496 543.829333l30.165333-30.165333-415.829333-415.914667a42.837333 42.837333 0 0 0-60.288 0 42.538667 42.538667 0 0 0 0 60.330667l355.413333 355.498667-355.413333 355.285333a42.496 42.496 0 0 0 0 60.288c16.64 16.64 43.861333 16.469333 60.288 0.042667l383.914667-383.701334 1.749333-1.664z"
|
||||
fill="currentColor"
|
||||
p-id="5071"
|
||||
></path>
|
||||
</svg>-->
|
||||
<div class="content">
|
||||
<ul ref="ulElemRef">
|
||||
<li v-for="(item, index) in data" :key="index" class="item" @click="handleQuestion(item.descr)">
|
||||
<div class="question-descr">
|
||||
<Icon v-if="item.icon" :icon="item.icon" size="20"></Icon>
|
||||
<svg v-else width="14px" height="14px" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M18.9839 1.85931C19.1612 1.38023 19.8388 1.38023 20.0161 1.85931L20.5021 3.17278C20.5578 3.3234 20.6766 3.44216 20.8272 3.49789L22.1407 3.98392C22.6198 4.1612 22.6198 4.8388 22.1407 5.01608L20.8272 5.50211C20.6766 5.55784 20.5578 5.6766 20.5021 5.82722L20.0161 7.14069C19.8388 7.61977 19.1612 7.61977 18.9839 7.14069L18.4979 5.82722C18.4422 5.6766 18.3234 5.55784 18.1728 5.50211L16.8593 5.01608C16.3802 4.8388 16.3802 4.1612 16.8593 3.98392L18.1728 3.49789C18.3234 3.44216 18.4422 3.3234 18.4979 3.17278L18.9839 1.85931zM13.5482 4.07793C13.0164 2.64069 10.9836 2.64069 10.4518 4.07793L8.99368 8.01834C8.82648 8.47021 8.47021 8.82648 8.01834 8.99368L4.07793 10.4518C2.64069 10.9836 2.64069 13.0164 4.07793 13.5482L8.01834 15.0063C8.47021 15.1735 8.82648 15.5298 8.99368 15.9817L10.4518 19.9221C10.9836 21.3593 13.0164 21.3593 13.5482 19.9221L15.0063 15.9817C15.1735 15.5298 15.5298 15.1735 15.9817 15.0063L19.9221 13.5482C21.3593 13.0164 21.3593 10.9836 19.9221 10.4518L15.9817 8.99368C15.5298 8.82648 15.1735 8.47021 15.0063 8.01834L13.5482 4.07793zM5.01608 16.8593C4.8388 16.3802 4.1612 16.3802 3.98392 16.8593L3.49789 18.1728C3.44216 18.3234 3.3234 18.4422 3.17278 18.4979L1.85931 18.9839C1.38023 19.1612 1.38023 19.8388 1.85931 20.0161L3.17278 20.5021C3.3234 20.5578 3.44216 20.6766 3.49789 20.8272L3.98392 22.1407C4.1612 22.6198 4.8388 22.6198 5.01608 22.1407L5.50211 20.8272C5.55784 20.6766 5.6766 20.5578 5.82722 20.5021L7.14069 20.0161C7.61977 19.8388 7.61977 19.1612 7.14069 18.9839L5.82722 18.4979C5.6766 18.4422 5.55784 18.3234 5.50211 18.1728L5.01608 16.8593z"></path></svg>
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- <svg
|
||||
v-if="btnShow"
|
||||
class="rightBtn"
|
||||
:class="rightBtnStatus"
|
||||
t="1710296339017"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="5070"
|
||||
@click="onScroll('next')"
|
||||
>
|
||||
<path
|
||||
d="M970.496 543.829333l30.165333-30.165333-415.829333-415.914667a42.837333 42.837333 0 0 0-60.288 0 42.538667 42.538667 0 0 0 0 60.330667l355.413333 355.498667-355.413333 355.285333a42.496 42.496 0 0 0 0 60.288c16.64 16.64 43.861333 16.469333 60.288 0.042667l383.914667-383.701334 1.749333-1.664z"
|
||||
fill="currentColor"
|
||||
p-id="5071"
|
||||
></path>
|
||||
</svg>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script name="presetQuestion" setup lang="ts">
|
||||
import {ref, onMounted, onBeforeUnmount, watch} from 'vue';
|
||||
const emit = defineEmits(['outQuestion']);
|
||||
const props = defineProps({
|
||||
quickCommandData:{ type: Object },
|
||||
});
|
||||
const data = ref(props.quickCommandData);
|
||||
const leftBtnStatus = ref('');
|
||||
const rightBtnStatus = ref('');
|
||||
const rightBtn = ref('');
|
||||
const ulElemRef = ref(null);
|
||||
const btnShow = ref(false);
|
||||
let timer = null;
|
||||
const handleScroll = (e) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const scrollLeft = e.target.scrollLeft;
|
||||
const offsetWidth = e.target.offsetWidth;
|
||||
const scrollWidth = e.target.scrollWidth;
|
||||
if (scrollWidth > offsetWidth) {
|
||||
btnShow.value = true;
|
||||
} else {
|
||||
btnShow.value = false;
|
||||
}
|
||||
if (scrollLeft <= 0) {
|
||||
leftBtnStatus.value = 'disabled';
|
||||
} else if (scrollWidth - offsetWidth == scrollLeft) {
|
||||
rightBtnStatus.value = 'disabled';
|
||||
} else {
|
||||
leftBtnStatus.value = '';
|
||||
rightBtnStatus.value = '';
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
const onScroll = (flag) => {
|
||||
const offsetWidth = ulElemRef.value.offsetWidth;
|
||||
if (flag == 'prev') {
|
||||
ulElemRef.value.scrollLeft = ulElemRef.value.scrollLeft - offsetWidth;
|
||||
} else if (flag == 'next') {
|
||||
ulElemRef.value.scrollLeft = ulElemRef.value.scrollLeft + offsetWidth;
|
||||
}
|
||||
};
|
||||
const handleQuestion = (item) => {
|
||||
emit('outQuestion', item);
|
||||
};
|
||||
|
||||
watch(()=>props.quickCommandData, (val) => {
|
||||
data.value = props.quickCommandData;
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
ulElemRef.value.addEventListener('scroll', handleScroll, false);
|
||||
handleScroll({ target: ulElemRef.value });
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
ulElemRef.value.removeEventListener('scroll', handleScroll);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.presetQuestion-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
color: #c6c2c2;
|
||||
&.leftBtn {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
/* 隐藏所有滚动条 */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
ul:hover {
|
||||
&::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
.item {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 10px;
|
||||
width: max-content;
|
||||
margin-right: 6px;
|
||||
white-space: nowrap;
|
||||
transition: all 300ms ease;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
border-color: @primary-color;
|
||||
}
|
||||
}
|
||||
.question-descr{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
span{
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { App } from 'vue';
|
||||
import { router } from "/@/router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { LAYOUT } from "@/router/constant";
|
||||
|
||||
const ChatRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/ai/app/chat/:appId",
|
||||
name: "ai-chat-@appId-@modeType",
|
||||
component: () => import("/@/views/super/airag/aiapp/chat/AiChat.vue"),
|
||||
meta: {
|
||||
title: 'AI聊天',
|
||||
ignoreAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/ai/app/chatIcon/:appId",
|
||||
name: "ai-chatIcon-@appId",
|
||||
component: () => import("/@/views/super/airag/aiapp/chat/AiChatIcon.vue"),
|
||||
meta: {
|
||||
title: 'AI聊天',
|
||||
ignoreAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/ai/chat',
|
||||
name: 'aiChat',
|
||||
component: LAYOUT,
|
||||
meta: {
|
||||
title: 'ai聊天',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "/ai/chat/:appId",
|
||||
name: "ai-chat-@appId",
|
||||
component: () => import("/@/views/super/airag/aiapp/chat/AiChat.vue"),
|
||||
meta: {
|
||||
title:'AI助手',
|
||||
ignoreAuth: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/ai/chat",
|
||||
name: "ai-chat",
|
||||
component: () => import("/@/views/super/airag/aiapp/chat/AiChat.vue"),
|
||||
meta: {
|
||||
title:'AI助手',
|
||||
ignoreAuth: false,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/ai/chat/portal',
|
||||
name: 'ai-chat-portal',
|
||||
component: () => import('/@/views/super/airag/aiapp/chat/portal/AppPortal.vue'),
|
||||
meta: {
|
||||
title: 'AI聊天',
|
||||
ignoreAuth: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** 注册路由 */
|
||||
export async function register(app: App) {
|
||||
await registerMyAppRouter(app);
|
||||
console.log('[聊天路由] 注册完成!');
|
||||
}
|
||||
|
||||
async function registerMyAppRouter(_: App) {
|
||||
for(let appRoute of ChatRoutes){
|
||||
await router.addRoute(appRoute);
|
||||
}
|
||||
}
|
||||
338
jeecgboot-vue3/src/views/super/airag/aiapp/chat/slide.vue
Normal file
338
jeecgboot-vue3/src/views/super/airag/aiapp/chat/slide.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="slide-wrap">
|
||||
<div class="header">
|
||||
<img class="header-image" :src="getImage()" />
|
||||
<div class="header-name">{{ appData.name || 'AI助手' }}</div>
|
||||
</div>
|
||||
<div class="createArea">
|
||||
<a-button type="dashed" @click="handleCreate">新建聊天</a-button>
|
||||
</div>
|
||||
<div class="historyArea">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(item, index) in dataSource.history"
|
||||
:key="item.id"
|
||||
class="list"
|
||||
:class="[item.id == dataSource.active ? 'active' : 'normal', dataSource.history.length == 1 ? 'last' : '']"
|
||||
@click="handleToggleChat(item, index)"
|
||||
>
|
||||
<i class="icon message">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--ri"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M2 8.994A5.99 5.99 0 0 1 8 3h8c3.313 0 6 2.695 6 5.994V21H8c-3.313 0-6-2.695-6-5.994zM20 19V8.994A4.004 4.004 0 0 0 16 5H8a3.99 3.99 0 0 0-4 3.994v6.012A4.004 4.004 0 0 0 8 19zm-6-8h2v2h-2zm-6 0h2v2H8z"
|
||||
></path>
|
||||
</svg>
|
||||
</i>
|
||||
<a-input
|
||||
class="title"
|
||||
ref="inputRef"
|
||||
v-if="item.isEdit"
|
||||
:defaultValue="item.title"
|
||||
placeholder="请输入标题"
|
||||
@change="handleInputChange"
|
||||
@keyup.enter="inputBlur(item)"
|
||||
/>
|
||||
<span class="title" v-else>{{ item.title }}</span>
|
||||
<span class="icon edit" @click.stop="handleEdit(item)" v-if="!item.isEdit && !item.disabled">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" role="img" class="iconify iconify--ri" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6.414 15.89L16.556 5.748l-1.414-1.414L5 14.476v1.414zm.829 2H3v-4.243L14.435 2.212a1 1 0 0 1 1.414 0l2.829 2.829a1 1 0 0 1 0 1.414zM3 19.89h18v2H3z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="icon del">
|
||||
<a-popconfirm
|
||||
:overlayStyle="{ 'z-index': 9999 }"
|
||||
title="确定删除此记录?"
|
||||
placement="bottom"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm.stop="handleDel(item)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" role="img" class="iconify iconify--ri" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17 6h5v2h-2v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8H2V6h5V3a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1zm1 2H6v12h12zm-9 3h2v6H9zm4 0h2v6h-2zM9 4v2h6V4z"
|
||||
></path>
|
||||
</svg>
|
||||
</a-popconfirm>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="left-footer" v-if="source!='chatJs'">
|
||||
AI客服由
|
||||
<a style="color: #4183c4;margin-left: 2px;margin-right: 2px" href="https://www.qiaoqiaoyun.com/aiCustomerService" target="_blank">
|
||||
JEECG AI
|
||||
</a>
|
||||
提供
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import { getFileAccessHttpUrl } from '@/utils/common/compUtils';
|
||||
import defaultImg from '../img/ailogo.png';
|
||||
const props = defineProps(['dataSource', 'appData','source']);
|
||||
const emit = defineEmits(['save', 'click', 'reloadRight', 'prologue']);
|
||||
const inputRef = ref(null);
|
||||
const router = useRouter();
|
||||
let inputValue = '';
|
||||
//新建聊天
|
||||
const handleCreate = () => {
|
||||
const uuid = getUuid();
|
||||
props.dataSource.history.unshift({ title: '新建聊天', id: uuid, isEdit: false, disabled: true });
|
||||
// 新建第一个(需要高亮选中)
|
||||
props.dataSource.active = uuid;
|
||||
emit('click', "新建聊天", 0);
|
||||
};
|
||||
// 切换聊天
|
||||
const handleToggleChat = (item, index) => {
|
||||
if (item.id != props.dataSource.active) {
|
||||
props.dataSource.active = item.id;
|
||||
emit('click', item.title, index);
|
||||
}
|
||||
};
|
||||
const handleInputChange = (e) => {
|
||||
inputValue = e.target.value.trim();
|
||||
};
|
||||
// 失去焦点
|
||||
const inputBlur = (item) => {
|
||||
item.isEdit = false;
|
||||
item.title = inputValue;
|
||||
defHttp
|
||||
.put(
|
||||
{
|
||||
url: '/airag/chat/conversation/update/title',
|
||||
params: { id: item.id, title: inputValue },
|
||||
},
|
||||
{ joinParamsToUrl: true }
|
||||
)
|
||||
.then((res) => {});
|
||||
};
|
||||
// 编辑
|
||||
const handleEdit = (item) => {
|
||||
console.log(item);
|
||||
item.isEdit = true;
|
||||
inputValue = item.title;
|
||||
};
|
||||
// 保存
|
||||
const handleSave = (item) => {
|
||||
item.isEdit = false;
|
||||
item.title = inputValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除
|
||||
* @param data
|
||||
*/
|
||||
function handleDel(data) {
|
||||
const findIndex = props.dataSource.history.findIndex((item) => item.id == data.id);
|
||||
if (findIndex != -1) {
|
||||
props.dataSource.history.splice(findIndex, 1);
|
||||
// 删除的是当前active的,active往前移,前面没了往后移。
|
||||
if (props.dataSource.history.length) {
|
||||
if (props.dataSource.active == data.id) {
|
||||
if (findIndex > 0) {
|
||||
props.dataSource.active = props.dataSource.history[findIndex - 1].id;
|
||||
} else {
|
||||
props.dataSource.active = props.dataSource.history[0].id;
|
||||
}
|
||||
}
|
||||
emit('click', props.dataSource.history[0].title, findIndex);
|
||||
} else {
|
||||
// 删没了(删除了最后一个)
|
||||
handleCreate();
|
||||
}
|
||||
}
|
||||
//update-begin---author:wangshuai---date:2025-03-12---for:【QQYUN-11560】新建聊天内容为空,无法删除---
|
||||
if(data.disabled){
|
||||
return;
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-12---for:【QQYUN-11560】新建聊天内容为空,无法删除---
|
||||
defHttp.delete({
|
||||
url: '/airag/chat/conversation/' + data.id,
|
||||
},{ isTransformResponse: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片
|
||||
*/
|
||||
function getImage() {
|
||||
return props.appData.icon ? getFileAccessHttpUrl(props.appData.icon) : defaultImg;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => inputRef.value,
|
||||
(newVal: any) => {
|
||||
if (newVal?.length) {
|
||||
newVal[0].focus();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 指定长度和基数
|
||||
const getUuid = (len = 10, radix = 16) => {
|
||||
var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
|
||||
var uuid: any = [],
|
||||
i;
|
||||
radix = radix || chars.length;
|
||||
|
||||
if (len) {
|
||||
for (i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)];
|
||||
} else {
|
||||
var r;
|
||||
uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
|
||||
uuid[14] = '4';
|
||||
for (i = 0; i < 36; i++) {
|
||||
if (!uuid[i]) {
|
||||
r = 0 | (Math.random() * 16);
|
||||
uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r];
|
||||
}
|
||||
}
|
||||
}
|
||||
return uuid.join('');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.slide-wrap {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.historyArea {
|
||||
padding: 20px;
|
||||
padding-top: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
margin-bottom: 20px;
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
.historyArea ul li:hover {
|
||||
.del {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.createArea {
|
||||
padding: 20px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.ant-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.list {
|
||||
width: 100%;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
&:hover,
|
||||
&.active {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
.edit,
|
||||
.save,
|
||||
.del {
|
||||
display: none;
|
||||
}
|
||||
&.active {
|
||||
.edit,
|
||||
.save,
|
||||
.del {
|
||||
display: block;
|
||||
}
|
||||
&.last {
|
||||
.del {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.message {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.edit {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
&.ant-input {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
:deep(.ant-popover) {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
:deep(.ant-popconfirm) {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 20px 4px 0 4px;
|
||||
margin-left: 16px;
|
||||
.header-image {
|
||||
height: 35px;
|
||||
width: 35px;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.header-name {
|
||||
align-self: center;
|
||||
color: #1d2939;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
.left-footer{
|
||||
display:flex;
|
||||
margin-right: 20px;
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 50px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
||||
html.dark {
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
color: #abb2bf;
|
||||
background: #282c34;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-operator,
|
||||
.hljs-pattern-match {
|
||||
color: #f92672;
|
||||
}
|
||||
|
||||
.hljs-function,
|
||||
.hljs-pattern-match .hljs-constructor {
|
||||
color: #61aeee;
|
||||
}
|
||||
|
||||
.hljs-function .hljs-params {
|
||||
color: #a6e22e;
|
||||
}
|
||||
|
||||
.hljs-function .hljs-params .hljs-typing {
|
||||
color: #fd971f;
|
||||
}
|
||||
|
||||
.hljs-module-access .hljs-module {
|
||||
color: #7e57c2;
|
||||
}
|
||||
|
||||
.hljs-constructor {
|
||||
color: #e2b93d;
|
||||
}
|
||||
|
||||
.hljs-constructor .hljs-string {
|
||||
color: #9ccc65;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #b18eb1;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-formula {
|
||||
color: #c678dd;
|
||||
}
|
||||
|
||||
.hljs-deletion,
|
||||
.hljs-name,
|
||||
.hljs-section,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: #56b6c2;
|
||||
}
|
||||
|
||||
.hljs-addition,
|
||||
.hljs-attribute,
|
||||
.hljs-meta .hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-string {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-class .hljs-title,
|
||||
.hljs-title.class_ {
|
||||
color: #e6c07b;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-number,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable {
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
.hljs-bullet,
|
||||
.hljs-link,
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-symbol,
|
||||
.hljs-title {
|
||||
color: #61aeee;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
padding: 3px 5px;
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.hljs {
|
||||
color: #383a42;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #a0a1a7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-formula,
|
||||
.hljs-keyword {
|
||||
color: #a626a4;
|
||||
}
|
||||
|
||||
.hljs-deletion,
|
||||
.hljs-name,
|
||||
.hljs-section,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #e45649;
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: #0184bb;
|
||||
}
|
||||
|
||||
.hljs-addition,
|
||||
.hljs-attribute,
|
||||
.hljs-meta .hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-string {
|
||||
color: #50a14f;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-number,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable {
|
||||
color: #986801;
|
||||
}
|
||||
|
||||
.hljs-bullet,
|
||||
.hljs-link,
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-symbol,
|
||||
.hljs-title {
|
||||
color: #4078f2;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-class .hljs-title,
|
||||
.hljs-title.class_ {
|
||||
color: #c18401;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
132
jeecgboot-vue3/src/views/super/airag/aiapp/chat/style/style.less
Normal file
132
jeecgboot-vue3/src/views/super/airag/aiapp/chat/style/style.less
Normal file
@@ -0,0 +1,132 @@
|
||||
.markdown-body {
|
||||
background-color: transparent;
|
||||
font-size: 14px;
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
pre code,
|
||||
pre tt {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.highlight pre,
|
||||
pre {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
&-wrapper {
|
||||
position: relative;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
&-header {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
color: #b3b3b3;
|
||||
|
||||
&__copy {
|
||||
cursor: pointer;
|
||||
margin-left: 0.5rem;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: #65a665;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.markdown-body-generate > dd:last-child:after,
|
||||
&.markdown-body-generate > dl:last-child:after,
|
||||
&.markdown-body-generate > dt:last-child:after,
|
||||
&.markdown-body-generate > h1:last-child:after,
|
||||
&.markdown-body-generate > h2:last-child:after,
|
||||
&.markdown-body-generate > h3:last-child:after,
|
||||
&.markdown-body-generate > h4:last-child:after,
|
||||
&.markdown-body-generate > h5:last-child:after,
|
||||
&.markdown-body-generate > h6:last-child:after,
|
||||
&.markdown-body-generate > li:last-child:after,
|
||||
&.markdown-body-generate > ol:last-child li:last-child:after,
|
||||
&.markdown-body-generate > p:last-child:after,
|
||||
&.markdown-body-generate > pre:last-child code:after,
|
||||
&.markdown-body-generate > td:last-child:after,
|
||||
&.markdown-body-generate > ul:last-child li:last-child:after {
|
||||
animation: blink 1s steps(5, start) infinite;
|
||||
color: #000;
|
||||
content: '_';
|
||||
font-weight: 700;
|
||||
margin-left: 3px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.markdown-body {
|
||||
&.markdown-body-generate > dd:last-child:after,
|
||||
&.markdown-body-generate > dl:last-child:after,
|
||||
&.markdown-body-generate > dt:last-child:after,
|
||||
&.markdown-body-generate > h1:last-child:after,
|
||||
&.markdown-body-generate > h2:last-child:after,
|
||||
&.markdown-body-generate > h3:last-child:after,
|
||||
&.markdown-body-generate > h4:last-child:after,
|
||||
&.markdown-body-generate > h5:last-child:after,
|
||||
&.markdown-body-generate > h6:last-child:after,
|
||||
&.markdown-body-generate > li:last-child:after,
|
||||
&.markdown-body-generate > ol:last-child li:last-child:after,
|
||||
&.markdown-body-generate > p:last-child:after,
|
||||
&.markdown-body-generate > pre:last-child code:after,
|
||||
&.markdown-body-generate > td:last-child:after,
|
||||
&.markdown-body-generate > ul:last-child li:last-child:after {
|
||||
color: #65a665;
|
||||
}
|
||||
}
|
||||
|
||||
.message-reply {
|
||||
.whitespace-pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.highlight pre,
|
||||
pre {
|
||||
background-color: #282c34;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 533px) {
|
||||
.markdown-body .code-block-wrapper {
|
||||
padding: unset;
|
||||
|
||||
code {
|
||||
padding: 24px 16px 16px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"prompt": "# 角色\n你是一个犀利的电影解说员,可以使用尖锐幽默的语言,向用户讲解电影剧情、介绍最新上映的电影,还可以用普通人都可以理解的语言讲解电影相关知识。\n\n## 技能\n### 技能 1: 推荐最新上映的电影\n1. 当用户请你推荐最新电影时,需要先了解用户喜欢哪种类型片。如果你已经知道了,请跳过这一步,在询问时可以用“请问您喜欢什么类型的电影呢亲”。\n2. 如果你并不知道用户所说的电影,可以使用 工具搜索电影,了解电影类型。\n3. 根据用户的电影偏好,推荐几部正在上映和即将上映的电影,在推荐开头可以说“好的亲,以下是为您推荐的电影”。\n===回复示例===\n - \uD83C\uDFAC 电影名: <电影名>\n - \uD83D\uDD50 上映时间: <电影在中国大陆的上映的日期>\n - \uD83D\uDCA1 电影简介: <100字总结这部电影的剧情摘要>\n===示例结束===\n\n### 技能 2: 介绍电影\n1. 当用户说介绍某一部电影,请使用工具 搜索电影介绍的链接,在收到需求时可以回应“好嘞亲,马上为您查找相关电影介绍”。\n2. 如果此时获取的信息不够全面,可以继续使用 工具 打开搜索结果中的相关链接,以了解电影详情。\n3. 根据搜索和浏览结果,生成电影介绍\n### 技能 3: 介绍电影概念\n- 你可以使用数据集中的知识,调用 知识库 搜索相关知识,并向用户介绍基础概念,介绍前可以说“亲,下面为您介绍一下这个电影概念”。\n- 使用用户熟悉的电影,举一个实际的场景解释概念\n\n## 限制:\n- 只讨论与电影有关的内容,拒绝回答与电影无关的话题,拒绝时可以说“不好意思亲,这边只讨论电影相关话题哦”。\n- 所输出的内容必须按照给定的格式进行组织,不能偏离框架要求,在表述中合理运用常用语。\n- 总结部分不能超过 100 字。\n- 只会输出知识库中已有内容, 不在知识库中的书籍, 通过 工具去了解。\n- 请使用 Markdown 的 ^^ 形式说明引用来源。”",
|
||||
"prologue": "嘿,亲!我对电影那可是门儿清,能给你带来超棒的电影体验。",
|
||||
"presetQuestion": [{"key": 1,"descr": "有啥好看的动作片推荐不?"},{"key": 2,"descr":"介绍下《流浪地球 3》呗。"},{"key": 3,"descr":"啥是电影蒙太奇呀?"}]
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" width="600px" :title="title" @ok="handleOk" @cancel="handleCancel">
|
||||
<div class="flex header">
|
||||
<JInput
|
||||
@pressEnter="loadFlowData"
|
||||
class="header-search"
|
||||
size="small"
|
||||
v-model:value="searchText"
|
||||
placeholder="请输入流程名称,回车搜索"
|
||||
/>
|
||||
</div>
|
||||
<a-row :span="24">
|
||||
<a-col :span="12" v-for="item in flowList" @click="handleSelect(item)">
|
||||
<!-- begin 流程选择支持单选和多选 -->
|
||||
<a-card :style="getCardStyle(item)" hoverable class="checkbox-card" :body-style="{ width: '100%' }">
|
||||
<div style="display: flex; width: 100%;align-items:center; justify-content: space-between">
|
||||
<div style="display: flex; align-items:center; flex: 1; overflow: hidden; margin-right: 10px;">
|
||||
<img :src="getImage(item.icon)" class="flow-icon"/>
|
||||
<div style="display: grid;margin-left: 5px;align-items: center">
|
||||
<span class="checkbox-name ellipsis">{{ item.name }}</span>
|
||||
<div class="flex text-status" v-if="item.metadata && item.metadata.length>0">
|
||||
<span class="tag-input">输入</span>
|
||||
<div v-for="(metaItem, index) in item.metadata">
|
||||
<a-tag color="#f2f3f8" class="tags-meadata">
|
||||
<span v-if="index<3" class="tag-text">{{ metaItem.field }}</span>
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-checkbox v-if="multiple" v-model:checked="item.checked" @click.stop @change="(e)=>handleChange(e,item)"></a-checkbox>
|
||||
<!-- end 流程选择支持单选和多选 -->
|
||||
</div>
|
||||
<div class="text-desc mt-10">
|
||||
{{ item.descr || '暂无描述' }}
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div v-if="showFooterSelection" class="use-select">
|
||||
<template v-if="!multiple">
|
||||
已选择 <span class="ellipsis" style="max-width: 100px">{{flowData.name}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
已选择 {{ flowId.length }} 个流程
|
||||
</template>
|
||||
<span style="margin-left: 8px; color: #3d79fb; cursor: pointer" @click="handleClearClick">清空</span>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="flowList.length > 0"
|
||||
:current="pageNo"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:total="total"
|
||||
:showQuickJumper="true"
|
||||
:showSizeChanger="true"
|
||||
@change="handlePageChange"
|
||||
class="list-footer"
|
||||
size="small"
|
||||
/>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref, computed } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModal, useModalInner } from '@/components/Modal';
|
||||
import { Pagination } from 'ant-design-vue';
|
||||
import {JInput} from "@/components/Form";
|
||||
import { list } from '@/views/super/airag/aiknowledge/AiKnowledgeBase.api';
|
||||
import knowledge from '/@/views/super/airag/aiknowledge/icon/knowledge.png';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
// import {pageApi} from "@/views/super/airag/aiflow/pages/api";
|
||||
import { defHttp } from "@/utils/http/axios";
|
||||
import { getFileAccessHttpUrl } from "@/utils/common/compUtils";
|
||||
import defaultFlowImg from "@/assets/images/ai/aiflow.png";
|
||||
|
||||
export default {
|
||||
name: 'AiAppAddFlowModal',
|
||||
components: {
|
||||
Pagination,
|
||||
BasicModal,
|
||||
JInput,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
props: {
|
||||
multiple:{ type: Boolean, default: false },
|
||||
// 排除的流程ID,多个逗号分隔
|
||||
excludedIds: { type: String, default: '' },
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const title = ref<string>('选择流程');
|
||||
//应用类型
|
||||
const flowId = ref<any>([]);
|
||||
//流程数据
|
||||
const flowList = ref<any>([]);
|
||||
//选中的数据
|
||||
const flowData = ref<any>({})
|
||||
//当前页数
|
||||
const pageNo = ref<number>(1);
|
||||
//每页条数
|
||||
const pageSize = ref<number>(10);
|
||||
//总条数
|
||||
const total = ref<number>(0);
|
||||
//搜索文本
|
||||
const searchText = ref<string>('');
|
||||
//可选择的页数
|
||||
const pageSizeOptions = ref<any>(['10', '20', '30']);
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
//update-begin---author:wangshuai---date:2025-12-24---for: 流程选择支持单选和多选 ---
|
||||
if (props.multiple) {
|
||||
flowId.value = data.flowId ? (Array.isArray(data.flowId) ? cloneDeep(data.flowId) : data.flowId.split(',')) : [];
|
||||
flowData.value = data.flowData ? cloneDeep(data.flowData) : [];
|
||||
} else {
|
||||
flowId.value = data.flowId ? cloneDeep(data.flowId) : '';
|
||||
flowData.value = data.flowData ? cloneDeep(data.flowData) : {};
|
||||
}
|
||||
setModalProps({ minHeight: 500, bodyStyle: { padding: '10px', height: 'calc(100% - 20px)', overflowY: 'auto' } });
|
||||
//update-end---author:wangshuai---date:2025-12-24---for:流程选择支持单选和多选---
|
||||
loadFlowData();
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
emit('success',{ flowId: flowId.value, flowData: flowData.value });
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
//复选框选中事件
|
||||
const handleSelect = (item) => {
|
||||
//update-begin---author:wangshuai---date:2025-12-24---for: 流程选择支持单选和多选 ---
|
||||
if(!props.multiple) {
|
||||
if (flowId.value === item.id) {
|
||||
flowId.value = "";
|
||||
flowData.value = null;
|
||||
return;
|
||||
}
|
||||
flowId.value = item.id;
|
||||
flowData.value = item;
|
||||
} else {
|
||||
item.checked = !item.checked;
|
||||
updateMultipleSelection(item);
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-12-24---for: 流程选择支持单选和多选 ---
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载AI流程
|
||||
*/
|
||||
function loadFlowData() {
|
||||
let params: Recordable = {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
column: 'createTime',
|
||||
order: 'desc',
|
||||
name: searchText.value,
|
||||
status: 'enable,release'
|
||||
};
|
||||
|
||||
// 排除的流程ID,多个逗号分隔
|
||||
if (props.excludedIds) {
|
||||
params.excludedIds = props.excludedIds;
|
||||
}
|
||||
|
||||
getAiFlowList(params).then((res) =>{
|
||||
if(res){
|
||||
for (const data of res.records) {
|
||||
data.metadata = getMetadata(data.metadata);
|
||||
//update-begin---author:wangshuai---date:2025-12-24---for: 流程选择支持单选和多选 ---
|
||||
if (props.multiple && Array.isArray(flowId.value) && flowId.value.includes(data.id)) {
|
||||
data.checked = true;
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-12-24---for: 流程选择支持单选和多选 ---
|
||||
}
|
||||
flowList.value = res.records;
|
||||
total.value = res.total;
|
||||
} else {
|
||||
flowList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getAiFlowList(params?: any) {
|
||||
return defHttp.get({url: '/airag/flow/list', params});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页改变事件
|
||||
* @param page
|
||||
* @param current
|
||||
*/
|
||||
function handlePageChange(page, current) {
|
||||
pageNo.value = page;
|
||||
pageSize.value = current;
|
||||
loadFlowData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空选中状态
|
||||
*/
|
||||
function handleClearClick() {
|
||||
//update-begin---author:wangshuai---date:2025-12-24---for: 流程选择支持单选和多选 ---
|
||||
if (!props.multiple) {
|
||||
flowId.value = "";
|
||||
flowData.value = null;
|
||||
} else {
|
||||
flowId.value = [];
|
||||
flowData.value = [];
|
||||
if (flowList.value && Array.isArray(flowList.value)) {
|
||||
flowList.value.forEach(item => item.checked = false);
|
||||
}
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-12-24---for: 流程选择支持单选和多选 ---
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图标
|
||||
*/
|
||||
function getImage(icon) {
|
||||
return icon ? getFileAccessHttpUrl(icon) : defaultFlowImg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输入输出参入
|
||||
*
|
||||
* @param metadata
|
||||
*/
|
||||
function getMetadata(metadata) {
|
||||
if (!metadata) {
|
||||
return [];
|
||||
}
|
||||
let parse = JSON.parse(metadata);
|
||||
let inputsArr = parse['inputs'];
|
||||
return [...inputsArr];
|
||||
}
|
||||
|
||||
/*===========begin 流程选择支持多选 ===========*/
|
||||
function handleChange(e, item) {
|
||||
updateMultipleSelection(item);
|
||||
}
|
||||
|
||||
function updateMultipleSelection(item) {
|
||||
if (item.checked) {
|
||||
if (!flowId.value.includes(item.id)) {
|
||||
flowId.value.push(item.id);
|
||||
flowData.value.push(item);
|
||||
}
|
||||
} else {
|
||||
const index = flowId.value.indexOf(item.id);
|
||||
if (index > -1) {
|
||||
flowId.value.splice(index, 1);
|
||||
flowData.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showFooterSelection = computed(() => {
|
||||
if (props.multiple) {
|
||||
return flowId.value && flowId.value.length > 0;
|
||||
}
|
||||
return !!flowId.value;
|
||||
});
|
||||
|
||||
function getCardStyle(item) {
|
||||
if (props.multiple) {
|
||||
return item.checked ? { border: '1px solid #3370ff' } : {};
|
||||
}
|
||||
return item.id === flowId.value ? { border: '1px solid #3370ff' } : {};
|
||||
}
|
||||
/*===========end 流程选择支持多选 ===========*/
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
flowList,
|
||||
flowId,
|
||||
handleSelect,
|
||||
pageNo,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
total,
|
||||
handlePageChange,
|
||||
knowledge,
|
||||
searchText,
|
||||
loadFlowData,
|
||||
handleClearClick,
|
||||
flowData,
|
||||
getImage,
|
||||
handleChange,
|
||||
getCardStyle,
|
||||
showFooterSelection,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.header {
|
||||
color: #646a73;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
.header-search {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-title {
|
||||
color: #1d2025;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.type-desc {
|
||||
color: #8f959e;
|
||||
font-weight: 400;
|
||||
}
|
||||
.list-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 210px;
|
||||
}
|
||||
.checkbox-card {
|
||||
margin-bottom: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.checkbox-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #354052;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
align-content: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: grid;
|
||||
}
|
||||
.use-select {
|
||||
color: #646a73;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
}
|
||||
.ellipsis {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flow-icon{
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
:deep(.ant-card .ant-card-body){
|
||||
padding:16px !important;
|
||||
}
|
||||
.header-create-by{
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.text-desc {
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
font-size: 12px;
|
||||
color: #676F83;
|
||||
}
|
||||
.mt-10{
|
||||
margin-top: 10px;
|
||||
}
|
||||
.flex{
|
||||
display: flex;
|
||||
}
|
||||
.text-status{
|
||||
font-size: 12px;
|
||||
color: #676F83;
|
||||
}
|
||||
.tag-text {
|
||||
display: flow;
|
||||
max-width: 48px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
color: #3a3f4f;
|
||||
}
|
||||
.tag-input{
|
||||
align-self: center;
|
||||
color: #707a97;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
margin-right: 6px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tags-meadata{
|
||||
padding-inline: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
font-weight: 500;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(.jeecg-modal-wrapper){
|
||||
height: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
height: 480px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" width="600px" :title="title" @ok="handleOk" @cancel="handleCancel">
|
||||
<div class="flex header">
|
||||
<a-input
|
||||
@pressEnter="loadKnowledgeData"
|
||||
class="header-search"
|
||||
size="small"
|
||||
v-model:value="searchText"
|
||||
placeholder="请输入知识库名称,回车搜索"
|
||||
></a-input>
|
||||
</div>
|
||||
<a-row :span="24">
|
||||
<a-col :span="12" v-for="item in appKnowledgeOption" @click="handleSelect(item)">
|
||||
<a-card :style="getCardStyle(item)" hoverable class="checkbox-card" :body-style="{ width: '100%' }">
|
||||
<div style="display: flex; width: 100%; justify-content: space-between">
|
||||
<div>
|
||||
<img class="checkbox-img" :src="knowledge" />
|
||||
<span class="checkbox-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<a-checkbox v-if="multiple" v-model:checked="item.checked" @click.stop class="quantum-checker" @change="(e)=>handleChange(e,item)"> </a-checkbox>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div v-if="knowledgeIds && knowledgeIds.length > 0" class="use-select">
|
||||
<template v-if="!multiple">
|
||||
已选择 <span class="ellipsis" style="max-width: 150px">{{knowledgeData.name}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
已选择 {{ knowledgeIds.length }} 知识库
|
||||
</template>
|
||||
<span style="margin-left: 8px; color: #3d79fb; cursor: pointer" @click="handleClearClick">清空</span>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="appKnowledgeOption.length > 0"
|
||||
:current="pageNo"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:total="total"
|
||||
:showQuickJumper="true"
|
||||
:showSizeChanger="true"
|
||||
@change="handlePageChange"
|
||||
class="list-footer"
|
||||
size="small"
|
||||
/>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModal, useModalInner } from '@/components/Modal';
|
||||
import { Pagination } from 'ant-design-vue';
|
||||
import { list } from '@/views/super/airag/aiknowledge/AiKnowledgeBase.api';
|
||||
import knowledge from '/@/views/super/airag/aiknowledge/icon/knowledge.png';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
export default {
|
||||
name: 'AiAppAddKnowledgeModal',
|
||||
components: {
|
||||
Pagination,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
props: {
|
||||
multiple:{ type: Boolean, default: true },
|
||||
type: { type: String, default: 'knowledge' }
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const title = ref<string>('添加关联知识库');
|
||||
|
||||
//app知识库
|
||||
const appKnowledgeOption = ref<any>([]);
|
||||
//应用类型
|
||||
const knowledgeIds = ref<any>([]);
|
||||
//应用数据
|
||||
const knowledgeData = ref<any>([]);
|
||||
//当前页数
|
||||
const pageNo = ref<number>(1);
|
||||
//每页条数
|
||||
const pageSize = ref<number>(10);
|
||||
//总条数
|
||||
const total = ref<number>(0);
|
||||
//搜索文本
|
||||
const searchText = ref<string>('');
|
||||
//可选择的页数
|
||||
const pageSizeOptions = ref<any>(['10', '20', '30']);
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
//update-begin---author:wangshuai---date:2025-12-25---for:知识库选择支持单选和多选---
|
||||
if (props.multiple) {
|
||||
knowledgeIds.value = data.knowledgeIds ? cloneDeep(data.knowledgeIds.split(',')) : [];
|
||||
knowledgeData.value = data.knowledgeDataList ? cloneDeep(data.knowledgeDataList) : [];
|
||||
} else {
|
||||
knowledgeIds.value = data.knowledgeIds ? cloneDeep(data.knowledgeIds) : '';
|
||||
knowledgeData.value = data.knowledgeData ? cloneDeep(data.knowledgeData) : {};
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-12-25---for:知识库选择支持单选和多选---
|
||||
setModalProps({ minHeight: 500, bodyStyle: { padding: '10px' } });
|
||||
loadKnowledgeData();
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
console.log("知识库确定选中的值",knowledgeData.value);
|
||||
//update-begin---author:wangshuai---date:2025-12-25---for:知识库选择支持单选和多选---
|
||||
if (props.multiple) {
|
||||
emit('success', knowledgeIds.value, knowledgeData.value);
|
||||
} else {
|
||||
emit('success', knowledgeIds.value, knowledgeData.value);
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-12-25---for:知识库选择支持单选和多选---
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
//复选框选中事件
|
||||
function handleSelect(item){
|
||||
//update-begin---author:wangshuai---date:2025-12-25---for:知识库选择支持单选和多选---
|
||||
if(!props.multiple) {
|
||||
if (knowledgeIds.value === item.id) {
|
||||
knowledgeIds.value = "";
|
||||
knowledgeData.value = null;
|
||||
return;
|
||||
}
|
||||
knowledgeIds.value = item.id;
|
||||
knowledgeData.value = item;
|
||||
} else {
|
||||
let id = item.id;
|
||||
const target = appKnowledgeOption.value.find((item) => item.id === id);
|
||||
if (target) {
|
||||
target.checked = !target.checked;
|
||||
}
|
||||
//存放选中的知识库的id
|
||||
if (!knowledgeIds.value || knowledgeIds.value.length == 0) {
|
||||
knowledgeIds.value.push(id);
|
||||
knowledgeData.value.push(item);
|
||||
console.log("知识库勾选或取消勾选复选框的值",knowledgeData.value);
|
||||
return;
|
||||
}
|
||||
let findIndex = knowledgeIds.value.findIndex((item) => item === id);
|
||||
if (findIndex === -1) {
|
||||
knowledgeIds.value.push(id);
|
||||
knowledgeData.value.push(item);
|
||||
} else {
|
||||
knowledgeIds.value.splice(findIndex, 1);
|
||||
knowledgeData.value.splice(findIndex, 1);
|
||||
}
|
||||
console.log("知识库勾选或取消勾选复选框的值",knowledgeData.value);
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-12-25---for:知识库选择支持单选和多选---
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载知识库
|
||||
*/
|
||||
function loadKnowledgeData() {
|
||||
let params = {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
name: searchText.value,
|
||||
type: props.type,
|
||||
};
|
||||
list(params).then((res) => {
|
||||
if (res.success) {
|
||||
if (props.multiple && knowledgeIds.value.length > 0) {
|
||||
for (const item of res.result.records) {
|
||||
if (knowledgeIds.value.includes(item.id)) {
|
||||
item.checked = true;
|
||||
}
|
||||
}
|
||||
appKnowledgeOption.value = res.result.records;
|
||||
} else {
|
||||
appKnowledgeOption.value = res.result.records;
|
||||
}
|
||||
total.value = res.result.total;
|
||||
} else {
|
||||
appKnowledgeOption.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页改变事件
|
||||
* @param page
|
||||
* @param current
|
||||
*/
|
||||
function handlePageChange(page, current) {
|
||||
pageNo.value = page;
|
||||
pageSize.value = current;
|
||||
loadKnowledgeData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空选中状态
|
||||
*/
|
||||
function handleClearClick() {
|
||||
//update-begin---author:wangshuai---date:2025-12-25---for:知识库选择支持单选和多选---
|
||||
if (!props.multiple) {
|
||||
knowledgeIds.value = "";
|
||||
knowledgeData.value = null;
|
||||
} else {
|
||||
knowledgeIds.value = [];
|
||||
knowledgeData.value = [];
|
||||
appKnowledgeOption.value.forEach((item) => {
|
||||
item.checked = false;
|
||||
});
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-12-25---for:知识库选择支持单选和多选---
|
||||
}
|
||||
|
||||
/**
|
||||
* 复选框选中事件
|
||||
*
|
||||
* @param e
|
||||
* @param item
|
||||
*/
|
||||
function handleChange(e, item) {
|
||||
if (e.target.checked) {
|
||||
knowledgeIds.value.push(item.id);
|
||||
knowledgeData.value.push(item);
|
||||
} else {
|
||||
let findIndex = knowledgeIds.value.findIndex((val) => val === item.id);
|
||||
if (findIndex != -1) {
|
||||
knowledgeIds.value.splice(findIndex, 1);
|
||||
knowledgeData.value.splice(findIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取卡片样式
|
||||
*
|
||||
* @param item
|
||||
*/
|
||||
function getCardStyle(item) {
|
||||
if (props.multiple) {
|
||||
return item.checked ? { border: '1px solid #3370ff' } : {};
|
||||
}
|
||||
return item.id === knowledgeIds.value ? { border: '1px solid #3370ff' } : {};
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
appKnowledgeOption,
|
||||
knowledgeIds,
|
||||
handleSelect,
|
||||
pageNo,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
total,
|
||||
handlePageChange,
|
||||
knowledge,
|
||||
searchText,
|
||||
loadKnowledgeData,
|
||||
handleClearClick,
|
||||
handleChange,
|
||||
getCardStyle,
|
||||
knowledgeData,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header {
|
||||
color: #646a73;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
.header-search {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-title {
|
||||
color: #1d2025;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.type-desc {
|
||||
color: #8f959e;
|
||||
font-weight: 400;
|
||||
}
|
||||
.list-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 10px;
|
||||
}
|
||||
.checkbox-card {
|
||||
margin-bottom: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.checkbox-img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.checkbox-name {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.use-select {
|
||||
color: #646a73;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" width="600px" :title="title" @ok="handleOk" @cancel="handleCancel">
|
||||
<div class="flex header">
|
||||
<a-input
|
||||
@keyup.enter="loadMcpData"
|
||||
class="header-search"
|
||||
size="small"
|
||||
v-model:value="searchText"
|
||||
placeholder="请输入MCP名称,回车搜索"
|
||||
></a-input>
|
||||
</div>
|
||||
<a-row :span="24">
|
||||
<a-col :span="12" v-for="item in mcpOption" :key="item.id" @click="handleSelect(item)">
|
||||
<a-card :body-style="{padding: '10px 12px'}" hoverable :class="['mcp-card', { 'is-active': item.checked }]">
|
||||
<div class="mcp-card-header">
|
||||
<div class="mcp-card-left">
|
||||
<img class="mcp-card-icon" :src="getIcon(item.icon)" />
|
||||
<div class="mcp-card-info">
|
||||
<div class="mcp-card-name" :title="item.name">{{ item.name }}</div>
|
||||
<div class="mcp-card-meta">
|
||||
<div class="pill type-pill" :title="'类型: '+(item.category === 'plugin' ? '插件' : 'MCP')">
|
||||
<Icon :icon="getCategoryIcon(item.category)" class="pill-icon" />
|
||||
<span class="pill-text">{{ item.category === 'plugin' ? '插件' : 'MCP' }}</span>
|
||||
</div>
|
||||
<div class="pill tool-pill" :title="getToolCount(item.metadata)+' 个工具'">
|
||||
<Icon icon="ant-design:tool-outlined" class="pill-icon" />
|
||||
<span class="pill-text">{{ getToolCount(item.metadata) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-checkbox v-model:checked="item.checked" @click.stop class="mcp-card-checker" @change="(e)=>handleChange(e,item)"> </a-checkbox>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div v-if="pluginIds.length > 0" class="use-select">
|
||||
已选择 {{ pluginIds.length }} 个MCP
|
||||
<span style="margin-left: 8px; color: #3d79fb; cursor: pointer" @click="handleClearClick">清空</span>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="mcpOption.length > 0"
|
||||
:current="pageNo"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:total="total"
|
||||
:showQuickJumper="true"
|
||||
:showSizeChanger="true"
|
||||
@change="handlePageChange"
|
||||
class="list-footer"
|
||||
size="small"
|
||||
/>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModalInner } from '@/components/Modal';
|
||||
import { Pagination } from 'ant-design-vue';
|
||||
import { list as mcpList } from '@/views/super/airag/aimcp/AiragMcp.api';
|
||||
import { getFileAccessHttpUrl } from '@/utils/common/compUtils';
|
||||
import defaultLogo from '@/views/super/airag/aimcp/imgs/mcpLogo.png';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
export default {
|
||||
name: 'AiAppAddMcpModal',
|
||||
components: {
|
||||
Pagination,
|
||||
BasicModal,
|
||||
Icon,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
const title = ref<string>('添加关联MCP');
|
||||
|
||||
const mcpOption = ref<any>([]);
|
||||
const pluginIds = ref<any>([]); // 仅存放id
|
||||
const pluginDataList = ref<any>([]); // 选中对象
|
||||
|
||||
const pageNo = ref<number>(1);
|
||||
const pageSize = ref<number>(10);
|
||||
const total = ref<number>(0);
|
||||
const searchText = ref<string>('');
|
||||
const pageSizeOptions = ref<any>(['10', '20', '30']);
|
||||
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
pluginIds.value = data.pluginIds ? [...data.pluginIds] : [];
|
||||
pluginDataList.value = data.pluginDataList ? [...data.pluginDataList] : [];
|
||||
setModalProps({ minHeight: 500, bodyStyle: { padding: '10px' } });
|
||||
loadMcpData();
|
||||
});
|
||||
|
||||
function getIcon(icon){
|
||||
return icon ? getFileAccessHttpUrl(icon) : defaultLogo;
|
||||
}
|
||||
|
||||
async function handleOk() {
|
||||
// 拼接插件结构,使用item的category字段
|
||||
const plugins = pluginDataList.value.map((item:any)=>({
|
||||
pluginId: item.id,
|
||||
pluginName: item.name,
|
||||
category: item.category || 'mcp'
|
||||
}));
|
||||
emit('success', pluginIds.value, pluginDataList.value, plugins);
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function handleSelect(item:any){
|
||||
const id = item.id;
|
||||
const target = mcpOption.value.find((it:any)=> it.id === id);
|
||||
if(target){
|
||||
target.checked = !target.checked;
|
||||
}
|
||||
if(!pluginIds.value || pluginIds.value.length===0){
|
||||
pluginIds.value.push(id);
|
||||
pluginDataList.value.push(item);
|
||||
return;
|
||||
}
|
||||
const findIndex = pluginIds.value.findIndex((val:any)=> val === id);
|
||||
if(findIndex === -1){
|
||||
pluginIds.value.push(id);
|
||||
pluginDataList.value.push(item);
|
||||
}else{
|
||||
pluginIds.value.splice(findIndex,1);
|
||||
pluginDataList.value.splice(findIndex,1);
|
||||
}
|
||||
}
|
||||
|
||||
function loadMcpData(){
|
||||
const params = { pageNo: pageNo.value, pageSize: pageSize.value, status: 'enable', synced: 1, name: searchText.value };
|
||||
mcpList(params).then((res:any)=>{
|
||||
if (res.records) {
|
||||
const records = res.records || [];
|
||||
if(pluginIds.value.length>0){
|
||||
for(const rec of records){
|
||||
if(pluginIds.value.includes(rec.id)){
|
||||
rec.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
mcpOption.value = records;
|
||||
total.value = res.total;
|
||||
}else{
|
||||
mcpOption.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handlePageChange(page:number, current:number){
|
||||
pageNo.value = page;
|
||||
pageSize.value = current;
|
||||
loadMcpData();
|
||||
}
|
||||
|
||||
function handleClearClick(){
|
||||
pluginIds.value = [];
|
||||
pluginDataList.value = [];
|
||||
mcpOption.value.forEach((item:any)=> item.checked = false);
|
||||
}
|
||||
|
||||
function handleChange(e:any, item:any){
|
||||
if(e.target.checked){
|
||||
pluginIds.value.push(item.id);
|
||||
pluginDataList.value.push(item);
|
||||
}else{
|
||||
const findIndex = pluginIds.value.findIndex((val:any)=> val === item.id);
|
||||
if(findIndex>-1){
|
||||
pluginIds.value.splice(findIndex,1);
|
||||
pluginDataList.value.splice(findIndex,1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具数量:从 metadata 中读取 tool_count
|
||||
function getToolCount(metadata: any): number {
|
||||
if (!metadata) return 0;
|
||||
let metaObj: any = metadata;
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
metaObj = JSON.parse(metadata);
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
const count = metaObj.tool_count || metaObj.toolCount || 0;
|
||||
return typeof count === 'number' ? count : parseInt(count, 10) || 0;
|
||||
}
|
||||
|
||||
// 类型图标映射
|
||||
function getTypeIcon(type?: string) {
|
||||
switch (type) {
|
||||
case 'sse':
|
||||
return 'ant-design:thunderbolt-outlined';
|
||||
case 'stdio':
|
||||
return 'ant-design:code-outlined';
|
||||
default:
|
||||
return 'ant-design:appstore-outlined';
|
||||
}
|
||||
}
|
||||
|
||||
// category图标映射
|
||||
function getCategoryIcon(category?: string) {
|
||||
if (category === 'plugin') {
|
||||
return 'ant-design:api-outlined';
|
||||
}
|
||||
return 'ant-design:tool-twotone';
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
mcpOption,
|
||||
pluginIds,
|
||||
pluginDataList,
|
||||
pageNo,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
total,
|
||||
handlePageChange,
|
||||
searchText,
|
||||
loadMcpData,
|
||||
handleClearClick,
|
||||
handleChange,
|
||||
handleSelect,
|
||||
getIcon,
|
||||
getToolCount,
|
||||
getTypeIcon,
|
||||
getCategoryIcon,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.header {
|
||||
color: #646a73;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
.header-search {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
.mcp-card {
|
||||
margin-bottom: 10px;
|
||||
margin-right: 10px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition: box-shadow 0.25s, border-color 0.25s;
|
||||
cursor: pointer;
|
||||
&.is-active {
|
||||
border-color: #3370ff;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
}
|
||||
.mcp-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
.mcp-card-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.mcp-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: #f5f6f7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mcp-card-info {
|
||||
margin-left: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mcp-card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.mcp-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.mcp-card-checker {
|
||||
margin-left: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px 2px 6px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
backdrop-filter: saturate(180%) blur(4px);
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.05);
|
||||
.pill-icon {
|
||||
margin-right: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.type-pill {
|
||||
background: linear-gradient(135deg,#e6f4ff,#f0f9ff);
|
||||
color:#0958d9;
|
||||
}
|
||||
.tool-pill {
|
||||
background: linear-gradient(135deg,#f5f6f7,#f0f1f2);
|
||||
color:#555;
|
||||
}
|
||||
.use-select {
|
||||
color: #646a73;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 20px;
|
||||
}
|
||||
.list-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" width="1000px" @ok="handleOk" @cancel="handleCancel" okText="替换" wrapClassName='ai-rag-generate-prompt-modal'>
|
||||
<div class="prompt">
|
||||
<div class="prompt-left">
|
||||
<div class="prompt-left-title">提示词生成器</div>
|
||||
<div class="prompt-left-desc">提示词生成器使用配置的模型来优化提示词,以获得更高的质量和更好的结构。请写出清晰详细的说明。</div>
|
||||
<a-divider></a-divider>
|
||||
<div class="prompt-left-try">
|
||||
<div class="prompt-left-try-title">试一试</div>
|
||||
</div>
|
||||
<div class="instructions">
|
||||
<div class="instructions-content" v-for="item in instructionsList" @click="instructionsClick(item.value)">
|
||||
<Icon :icon="item.icon" size="14" color="#676f83"></Icon>
|
||||
<div class="instructions-name">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prompt-left-textarea">
|
||||
<div class="command">
|
||||
<span style="margin-right: 5px">指令</span>
|
||||
<a-tooltip title="提示词库">
|
||||
<span @click="openPromptApps" style="color:#1890ff;cursor: pointer">
|
||||
<Icon icon="ant-design:bulb-outlined" color="#1890ff"></Icon>词库选择
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-textarea v-model:value="prompt" :autoSize="{ minRows: 8, maxRows: 8 }"></a-textarea>
|
||||
</div>
|
||||
<a-button @click="generatedPrompt" class="prompt-left-btn" type="primary" :loading="loading">
|
||||
<span style="align-items: center; display: flex" v-if="!loading">
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M18.9839 1.85931C19.1612 1.38023 19.8388 1.38023 20.0161 1.85931L20.5021 3.17278C20.5578 3.3234 20.6766 3.44216 20.8272 3.49789L22.1407 3.98392C22.6198 4.1612 22.6198 4.8388 22.1407 5.01608L20.8272 5.50211C20.6766 5.55784 20.5578 5.6766 20.5021 5.82722L20.0161 7.14069C19.8388 7.61977 19.1612 7.61977 18.9839 7.14069L18.4979 5.82722C18.4422 5.6766 18.3234 5.55784 18.1728 5.50211L16.8593 5.01608C16.3802 4.8388 16.3802 4.1612 16.8593 3.98392L18.1728 3.49789C18.3234 3.44216 18.4422 3.3234 18.4979 3.17278L18.9839 1.85931zM13.5482 4.07793C13.0164 2.64069 10.9836 2.64069 10.4518 4.07793L8.99368 8.01834C8.82648 8.47021 8.47021 8.82648 8.01834 8.99368L4.07793 10.4518C2.64069 10.9836 2.64069 13.0164 4.07793 13.5482L8.01834 15.0063C8.47021 15.1735 8.82648 15.5298 8.99368 15.9817L10.4518 19.9221C10.9836 21.3593 13.0164 21.3593 13.5482 19.9221L15.0063 15.9817C15.1735 15.5298 15.5298 15.1735 15.9817 15.0063L19.9221 13.5482C21.3593 13.0164 21.3593 10.9836 19.9221 10.4518L15.9817 8.99368C15.5298 8.82648 15.1735 8.47021 15.0063 8.01834L13.5482 4.07793zM5.01608 16.8593C4.8388 16.3802 4.1612 16.3802 3.98392 16.8593L3.49789 18.1728C3.44216 18.3234 3.3234 18.4422 3.17278 18.4979L1.85931 18.9839C1.38023 19.1612 1.38023 19.8388 1.85931 20.0161L3.17278 20.5021C3.3234 20.5578 3.44216 20.6766 3.49789 20.8272L3.98392 22.1407C4.1612 22.6198 4.8388 22.6198 5.01608 22.1407L5.50211 20.8272C5.55784 20.6766 5.6766 20.5578 5.82722 20.5021L7.14069 20.0161C7.61977 19.8388 7.61977 19.1612 7.14069 18.9839L5.82722 18.4979C5.6766 18.4422 5.55784 18.3234 5.50211 18.1728L5.01608 16.8593z"
|
||||
></path>
|
||||
</svg>
|
||||
<span style="margin-left: 4px">生成</span>
|
||||
</span>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="prompt-right">
|
||||
<div v-if="!loading && !content">
|
||||
<svg width="6em" height="6em" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M18.9839 1.85931C19.1612 1.38023 19.8388 1.38023 20.0161 1.85931L20.5021 3.17278C20.5578 3.3234 20.6766 3.44216 20.8272 3.49789L22.1407 3.98392C22.6198 4.1612 22.6198 4.8388 22.1407 5.01608L20.8272 5.50211C20.6766 5.55784 20.5578 5.6766 20.5021 5.82722L20.0161 7.14069C19.8388 7.61977 19.1612 7.61977 18.9839 7.14069L18.4979 5.82722C18.4422 5.6766 18.3234 5.55784 18.1728 5.50211L16.8593 5.01608C16.3802 4.8388 16.3802 4.1612 16.8593 3.98392L18.1728 3.49789C18.3234 3.44216 18.4422 3.3234 18.4979 3.17278L18.9839 1.85931zM13.5482 4.07793C13.0164 2.64069 10.9836 2.64069 10.4518 4.07793L8.99368 8.01834C8.82648 8.47021 8.47021 8.82648 8.01834 8.99368L4.07793 10.4518C2.64069 10.9836 2.64069 13.0164 4.07793 13.5482L8.01834 15.0063C8.47021 15.1735 8.82648 15.5298 8.99368 15.9817L10.4518 19.9221C10.9836 21.3593 13.0164 21.3593 13.5482 19.9221L15.0063 15.9817C15.1735 15.5298 15.5298 15.1735 15.9817 15.0063L19.9221 13.5482C21.3593 13.0164 21.3593 10.9836 19.9221 10.4518L15.9817 8.99368C15.5298 8.82648 15.1735 8.47021 15.0063 8.01834L13.5482 4.07793zM5.01608 16.8593C4.8388 16.3802 4.1612 16.3802 3.98392 16.8593L3.49789 18.1728C3.44216 18.3234 3.3234 18.4422 3.17278 18.4979L1.85931 18.9839C1.38023 19.1612 1.38023 19.8388 1.85931 20.0161L3.17278 20.5021C3.3234 20.5578 3.44216 20.6766 3.49789 20.8272L3.98392 22.1407C4.1612 22.6198 4.8388 22.6198 5.01608 22.1407L5.50211 20.8272C5.55784 20.6766 5.6766 20.5578 5.82722 20.5021L7.14069 20.0161C7.61977 19.8388 7.61977 19.1612 7.14069 18.9839L5.82722 18.4979C5.6766 18.4422 5.55784 18.3234 5.50211 18.1728L5.01608 16.8593z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>在左侧描述您的用例,</div>
|
||||
<div>编排预览将在此处显示。</div>
|
||||
</div>
|
||||
<div v-if="loading">
|
||||
<a-spin :spinning="loading" tip="为您编排应用程序中…"></a-spin>
|
||||
</div>
|
||||
<div v-if="content">
|
||||
<a-textarea v-model:value="content" :autoSize="{ minRows: 18, maxRows: 18 }"></a-textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</div>
|
||||
<!-- Ai提示词选择弹窗 -->
|
||||
<AiAppPromptMarketModal @register="registerAiPromptSelectModal" @ok="handleAiAppPromptOk"></AiAppPromptMarketModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import {useModal, useModalInner} from '@/components/Modal';
|
||||
import { promptGenerate } from '@/views/super/airag/aiapp/AiApp.api';
|
||||
import AiAppPromptMarketModal from "@/views/super/airag/aiapp/components/AiAppPromptMarketModal.vue";
|
||||
|
||||
export default {
|
||||
name: 'AiAppGeneratedPrompt',
|
||||
components: {
|
||||
AiAppPromptMarketModal,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['ok', 'register'],
|
||||
setup(props, { emit }) {
|
||||
//提示词
|
||||
const prompt = ref<string>('');
|
||||
//加载
|
||||
const loading = ref<boolean>(false);
|
||||
//显示文本
|
||||
const content = ref<string>('');
|
||||
//指令提示词
|
||||
const instructionsList = ref<any>([
|
||||
{ name: 'python代码助手', value: 'python', icon: 'ant-design:code-outlined' },
|
||||
{ name: '翻译器', value: 'translator', icon: 'ant-design:translation-outlined' },
|
||||
{ name: '会议助手', value: 'meeting', icon: 'ant-design:team-outlined' },
|
||||
{ name: '润色文章', value: 'article', icon: 'ant-design:profile-outlined' },
|
||||
{ name: 'sql生成器', value: 'sql', icon: 'ant-design:console-sql-outlined' },
|
||||
{ name: '旅行规划师', value: 'travel', icon: 'ant-design:car-outlined' },
|
||||
{ name: 'linux专家', value: 'linux', icon: 'ant-design:fund-projection-screen-outlined' },
|
||||
{ name: '内容提炼器', value: 'content', icon: 'ant-design:read-outlined' },
|
||||
]);
|
||||
//指令
|
||||
const tip = ref<any>({
|
||||
python: '你是一个python专家,可以帮助用户编写和纠错代码。',
|
||||
translator: '一个可以将多种语言翻译为中文的翻译器。',
|
||||
meeting: '将会议内容提炼总结,包括讨论主题、关键要点和待办事项。',
|
||||
article: '用高超的编辑技巧改进我的文章。',
|
||||
sql: '根据用户的描述,生成sql语句,要支持引导用户提供表结构',
|
||||
travel: '你是一个旅行规划师,擅长帮助用户轻松规划他们的旅行',
|
||||
linux: '你是一个linux专家,擅长解决各种linux相关的问题。',
|
||||
content: '你是一个阅读理解大师,可以阅读用户提供的文章,并提炼主要内容输出给用户。',
|
||||
});
|
||||
//注册提示词modal
|
||||
const [registerAiPromptSelectModal, { openModal: aiPromptSelectModalOpen }] = useModal();
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
content.value = '';
|
||||
loading.value = false;
|
||||
prompt.value = '';
|
||||
setModalProps({ height: 500 });
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
emit('ok', content.value);
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
//update-begin---author:wangshuai---date:2025-04-01---for:【QQYUN-11796】【AI】提示词生成器,改成异步---
|
||||
/**
|
||||
* 生成
|
||||
*/
|
||||
async function generatedPrompt() {
|
||||
content.value = '';
|
||||
loading.value = true;
|
||||
let readableStream = await promptGenerate({ prompt: encodeURIComponent(prompt.value) }).catch(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
const reader = readableStream.getReader();
|
||||
const decoder = new TextDecoder('UTF-8');
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
let result = decoder.decode(value, { stream: true });
|
||||
const lines = result.split('\n\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
const content = line.replace('data:', '').trim();
|
||||
if(!content){
|
||||
continue;
|
||||
}
|
||||
if(!content.endsWith('}')){
|
||||
buffer = buffer + line;
|
||||
continue;
|
||||
}
|
||||
buffer = "";
|
||||
renderText(content)
|
||||
} else {
|
||||
if(!line) {
|
||||
continue;
|
||||
}
|
||||
if(!line.endsWith('}')) {
|
||||
buffer = buffer + line;
|
||||
continue;
|
||||
}
|
||||
buffer = "";
|
||||
renderText(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染文本
|
||||
*
|
||||
* @param item
|
||||
*/
|
||||
function renderText(item) {
|
||||
try {
|
||||
let parse = JSON.parse(item);
|
||||
if (parse.event == 'MESSAGE') {
|
||||
content.value += parse.data.message;
|
||||
if(loading.value){
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
if (parse.event == 'MESSAGE_END') {
|
||||
loading.value = false;
|
||||
}
|
||||
if (parse.event == 'ERROR') {
|
||||
content.value = parse.data.message?parse.data.message:'生成失败,请稍后重试!'
|
||||
loading.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error parsing update:', error);
|
||||
}
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-04-01---for:【QQYUN-11796】【AI】提示词生成器,改成异步---
|
||||
|
||||
/**
|
||||
* 指令点击事件
|
||||
*/
|
||||
function instructionsClick(value) {
|
||||
prompt.value = tip.value[value];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开提示词库弹窗
|
||||
*/
|
||||
function openPromptApps() {
|
||||
aiPromptSelectModalOpen(true,{});
|
||||
}
|
||||
/**
|
||||
* 提示词回调
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleAiAppPromptOk(value) {
|
||||
content.value = value;
|
||||
}
|
||||
return {
|
||||
registerModal,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
prompt,
|
||||
generatedPrompt,
|
||||
instructionsList,
|
||||
loading,
|
||||
instructionsClick,
|
||||
content,
|
||||
openPromptApps,
|
||||
registerAiPromptSelectModal,
|
||||
handleAiAppPromptOk,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.prompt {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
.prompt-left {
|
||||
width: 50%;
|
||||
padding: 20px;
|
||||
border-right: 1px solid #10182814;
|
||||
.prompt-left-title {
|
||||
background: linear-gradient(92deg, #2250f2 -29.55%, #0ebcf3 75.22%);
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
line-height: 28px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
.prompt-left-desc {
|
||||
color: #676f83;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.prompt-left-try {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.prompt-left-try-title {
|
||||
color: #676f83;
|
||||
line-height: 18px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
.prompt-left-textarea {
|
||||
margin-top: 25px;
|
||||
.command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #101828;
|
||||
line-height: 15px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
.prompt-left-btn {
|
||||
width: 80px;
|
||||
margin-top: 10px;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
.prompt-right {
|
||||
padding: 20px;
|
||||
width: 50%;
|
||||
text-align: center;
|
||||
align-content: center;
|
||||
svg {
|
||||
color: #676f83;
|
||||
}
|
||||
}
|
||||
.instructions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.instructions-content {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
border-radius: 5px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.instructions-name {
|
||||
color: #354052;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 2px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
:deep(.ant-divider-horizontal) {
|
||||
margin: 12px 0;
|
||||
}
|
||||
</style>
|
||||
<style lang="less">
|
||||
.ai-rag-generate-prompt-modal {
|
||||
.jeecg-modal-content > .scroll-container {
|
||||
padding: 0;
|
||||
|
||||
& > .scrollbar__wrap {
|
||||
overflow: hidden;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" width="800px" :title="title" @ok="handleOk" @cancel="handleCancel">
|
||||
<template #title>
|
||||
<span style="display: flex">
|
||||
{{title}}
|
||||
<a-tooltip title="AI应用文档">
|
||||
<a style="color: unset" href="https://help.jeecg.com/aigc/guide/app" target="_blank">
|
||||
<Icon style="position:relative;left:2px;top:1px" icon="ant-design:question-circle-outlined"></Icon>
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<BasicForm @register="registerForm">
|
||||
<template #typeSlot="{ model, field }">
|
||||
<a-radio-group v-model:value="model[field]" style="display: flex">
|
||||
<a-card
|
||||
v-for="item in appTypeOption"
|
||||
style="margin-right: 10px; cursor: pointer; width: 100%"
|
||||
@click="model[field] = item.value"
|
||||
:style="model[field] === item.value ? { borderColor: '#3370ff' } : {}"
|
||||
>
|
||||
<a-radio :value="item.value">
|
||||
<div class="type-title">{{ item.title }}</div>
|
||||
<div class="type-desc">{{ item.desc }}</div>
|
||||
</a-radio>
|
||||
</a-card>
|
||||
</a-radio-group>
|
||||
</template>
|
||||
</BasicForm>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref, computed } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModal, useModalInner } from '@/components/Modal';
|
||||
|
||||
import BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { useForm } from '@/components/Form';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { formSchema } from '../AiApp.data';
|
||||
import { initDictOptions } from '@/utils/dict';
|
||||
import { saveApp } from '@/views/super/airag/aiapp/AiApp.api';
|
||||
|
||||
export default {
|
||||
name: 'AiAppModal',
|
||||
components: {
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
//保存或修改
|
||||
const isUpdate = ref<boolean>(false);
|
||||
|
||||
const title = computed<string>(() => isUpdate.value ? '修改应用' : '创建应用');
|
||||
|
||||
//app类型
|
||||
const appTypeOption = ref<any>([]);
|
||||
|
||||
//表单配置
|
||||
const [registerForm, { validate, resetFields, setFieldsValue }] = useForm({
|
||||
schemas: formSchema,
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
wrapperCol: { span: 24 },
|
||||
});
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
await resetFields();
|
||||
//update-begin---author:wangshuai---date:2025-03-11---for: 【QQYUN-11324】8.修改弹窗head---
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
if (unref(isUpdate)) {
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...data.record,
|
||||
});
|
||||
} else {
|
||||
await setFieldsValue({
|
||||
type: 'chatSimple',
|
||||
})
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-11---for:【QQYUN-11324】8.修改弹窗head---
|
||||
setModalProps({ minHeight: 500, bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
try {
|
||||
let values = await validate();
|
||||
setModalProps({ confirmLoading: true });
|
||||
let result = await saveApp(values);
|
||||
if (result) {
|
||||
//关闭弹窗
|
||||
closeModal();
|
||||
//update-begin---author:wangshuai---date:2025-03-11---for: 【QQYUN-11324】8.修改弹窗head---
|
||||
if(isUpdate.value){
|
||||
//刷新列表
|
||||
emit('success', values);
|
||||
}else{
|
||||
//刷新列表
|
||||
emit('success', result);
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-11---for: 【QQYUN-11324】8.修改弹窗head---
|
||||
}
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
//初始化AI应用类型
|
||||
initAppTypeOption();
|
||||
|
||||
function initAppTypeOption() {
|
||||
initDictOptions('ai_app_type').then((data) => {
|
||||
if (data && data.length > 0) {
|
||||
for (const datum of data) {
|
||||
if (datum.value === 'chatSimple') {
|
||||
datum['desc'] = '适合新手创建小助手';
|
||||
} else if (datum.value === 'chatFLow') {
|
||||
datum['desc'] = '适合高级用户自定义小助手的工作流';
|
||||
}
|
||||
}
|
||||
}
|
||||
appTypeOption.value = data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
registerForm,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
appTypeOption,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-title {
|
||||
color: #1d2025;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.type-desc {
|
||||
color: #8f959e;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!--手动录入text-->
|
||||
<template>
|
||||
<BasicModal title="参数设置" destroyOnClose @register="registerModal" :canFullscreen="false" width="560px" @ok="handleOk" @cancel="handleCancel">
|
||||
<AiModelSeniorForm ref="aiModelSeniorFormRef" :type="type"></AiModelSeniorForm>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModalInner } from '@/components/Modal';
|
||||
|
||||
import BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { MarkdownViewer } from '@/components/Markdown';
|
||||
import AiModelSeniorForm from '/@/views/super/airag/aimodel/components/AiModelSeniorForm.vue';
|
||||
|
||||
export default {
|
||||
name: 'AiAppParamsSettingModal',
|
||||
components: {
|
||||
MarkdownViewer,
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
AiModelSeniorForm,
|
||||
},
|
||||
emits: ['ok', 'register'],
|
||||
setup(props, { emit }) {
|
||||
let aiModelSeniorFormRef = ref()
|
||||
//类型
|
||||
const type = ref<string>('');
|
||||
//注册modal
|
||||
const [registerModal, { closeModal }] = useModalInner(async (data) => {
|
||||
type.value = data.type;
|
||||
if(data.type === 'model'){
|
||||
if(!data.metadata.hasOwnProperty("temperature") ){
|
||||
data.metadata['temperature'] = 0.7;
|
||||
}
|
||||
if(!data.metadata.hasOwnProperty("timeout") ){
|
||||
data.metadata['timeout'] = 60;
|
||||
}
|
||||
}else{
|
||||
if(!data.metadata.hasOwnProperty("topNumber") ){
|
||||
data.metadata['topNumber'] = 4;
|
||||
}
|
||||
if(!data.metadata.hasOwnProperty("similarity") ){
|
||||
data.metadata['similarity'] = 0.76;
|
||||
}
|
||||
if(!data.metadata.hasOwnProperty("timeout") ){
|
||||
data.metadata['timeout'] = 60;
|
||||
}
|
||||
}
|
||||
setTimeout(()=>{
|
||||
aiModelSeniorFormRef.value.setModalParams(data.metadata);
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* 弹窗点击事件
|
||||
*/
|
||||
function handleOk() {
|
||||
let emitChange = aiModelSeniorFormRef.value.emitChange();
|
||||
emit('ok',emitChange);
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗关闭事件
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
type,
|
||||
aiModelSeniorFormRef,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.header {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.content {
|
||||
margin-top: 20px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.title-tag {
|
||||
color: #477dee;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
destroyOnClose
|
||||
@register="registerModal"
|
||||
:canFullscreen="false"
|
||||
width="1000px"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
okText="使用"
|
||||
wrapClassName="ai-rag-generate-prompt-modal"
|
||||
:confirmLoading="loading"
|
||||
>
|
||||
<div class="prompt-market-content">
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-section">
|
||||
<a-input-search
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索提示词名称或描述"
|
||||
style="width: 300px"
|
||||
@search="handleSearch"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 提示词列表 -->
|
||||
<div class="prompt-list-section">
|
||||
<a-spin :spinning="loading">
|
||||
<template v-if="promptList.length > 0">
|
||||
<a-row :gutter="[24, 24]">
|
||||
<a-col v-for="item in promptList" :key="item.id" :xs="24" :sm="12" :md="8" :lg="8">
|
||||
<div class="prompt-card" @click="handleSelectPrompt(item)">
|
||||
<a-card :class="['prompt-item-card', { selected: selectedPrompt?.id === item.id }]" :hoverable="true" size="small">
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<span class="title-text">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-content">
|
||||
<p class="description" :title="item.description || item.desc">{{ item.description || item.desc }}</p>
|
||||
<div class="card-footer" >
|
||||
<span class="create-time">
|
||||
{{ formatTime(item.createTime) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<empty v-else description="暂无提示词数据" class="empty-state" />
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 分页区域 -->
|
||||
<div class="pagination-section">
|
||||
<Pagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:show-size-changer="true"
|
||||
:page-size-options="['10', '20', '30', '50']"
|
||||
:show-quick-jumper="true"
|
||||
@change="handlePageChange"
|
||||
@showSizeChange="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { Empty } from 'ant-design-vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModalInner } from '@/components/Modal';
|
||||
import { list } from '@/views/super/airag/aiprompts/AiragPrompts.api';
|
||||
import { formatToDateTime } from '@/utils/dateUtil';
|
||||
import { Pagination } from 'ant-design-vue';
|
||||
export default {
|
||||
name: 'AiAppPromptMarketModal',
|
||||
components: {
|
||||
BasicModal,
|
||||
Pagination,
|
||||
Empty,
|
||||
},
|
||||
emits: ['ok', 'register', 'select'],
|
||||
setup(props, { emit }) {
|
||||
// 提示词列表
|
||||
const promptList = ref<any[]>([]);
|
||||
// 加载状态
|
||||
const loading = ref<boolean>(false);
|
||||
// 搜索文本
|
||||
const searchText = ref<string>('');
|
||||
// 选中的提示词
|
||||
const selectedPrompt = ref<any>(null);
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 12,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// 注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async () => {
|
||||
loading.value = false;
|
||||
resetField();
|
||||
await getPromptList();
|
||||
setModalProps({
|
||||
height: 600,
|
||||
bodyStyle: { padding: '24px' },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function resetField() {
|
||||
promptList.value = [];
|
||||
selectedPrompt.value = null;
|
||||
searchText.value = '';
|
||||
pagination.current = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示词列表
|
||||
*/
|
||||
async function getPromptList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
pageNo: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
name: searchText.value ? `*${searchText.value}*` : '',
|
||||
};
|
||||
|
||||
const res = await list(params);
|
||||
console.log('获取提示词列表成功:', res);
|
||||
if (res?.records) {
|
||||
promptList.value = res?.records || [];
|
||||
pagination.total = res?.total || 0;
|
||||
} else {
|
||||
promptList.value = [];
|
||||
pagination.total = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取提示词列表失败:', error);
|
||||
promptList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
function handleSearch() {
|
||||
pagination.current = 1;
|
||||
getPromptList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理页码变化
|
||||
*/
|
||||
function handlePageChange(page: number, pageSize: number) {
|
||||
pagination.current = page;
|
||||
pagination.pageSize = pageSize;
|
||||
getPromptList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理页面大小变化
|
||||
*/
|
||||
function handleSizeChange(current: number, size: number) {
|
||||
pagination.current = current;
|
||||
pagination.pageSize = size;
|
||||
getPromptList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择提示词
|
||||
*/
|
||||
function handleSelectPrompt(item: any) {
|
||||
selectedPrompt.value = item;
|
||||
}
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
function formatTime(time) {
|
||||
console.log('formatTime:', formatToDateTime(time));
|
||||
return formatToDateTime(time);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
if (selectedPrompt.value) {
|
||||
emit('ok', selectedPrompt.value.content);
|
||||
} else {
|
||||
emit('ok');
|
||||
}
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
promptList,
|
||||
loading,
|
||||
searchText,
|
||||
selectedPrompt,
|
||||
pagination,
|
||||
handleSearch,
|
||||
handlePageChange,
|
||||
handleSizeChange,
|
||||
handleSelectPrompt,
|
||||
formatTime,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.prompt-market-content {
|
||||
min-height: 400px;
|
||||
padding: 16px;
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
|
||||
.ant-input-search {
|
||||
max-width: 400px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-list-section {
|
||||
.prompt-card {
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.prompt-item-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
height: 150px; // 增加卡片高度
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e8e8e8;
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px 16px;
|
||||
height: calc(100% - 48px); // 调整内容区域高度
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #1890ff;
|
||||
background-color: #e6f7ff; // 添加选中背景色
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
|
||||
.card-title .title-text {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.title-text {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1; // 使用flex让内容区域自适应
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between; // 让内容和时间信息在垂直方向上分布
|
||||
|
||||
.description {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 8px 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex: 1; // 让描述区域自适应
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 8px; // 调整间距
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.create-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
// 模态框整体样式
|
||||
.ai-rag-generate-prompt-modal {
|
||||
.ant-modal-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" width="800px" :title="title" @ok="handleOk" @cancel="handleCancel">
|
||||
<BasicForm @register="registerForm"></BasicForm>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModalInner } from '@/components/Modal';
|
||||
|
||||
import BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { useForm } from '@/components/Form';
|
||||
import { quickCommandFormSchema} from '../AiApp.data';
|
||||
|
||||
export default {
|
||||
name: 'AiAppQuickCommandModal',
|
||||
components: {
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['ok', 'update-ok', 'register'],
|
||||
setup(props, { emit }) {
|
||||
const title = ref<string>('添加指令');
|
||||
|
||||
//保存或修改
|
||||
const isUpdate = ref<boolean>(false);
|
||||
|
||||
//表单配置
|
||||
const [registerForm, { validate, resetFields, setFieldsValue }] = useForm({
|
||||
schemas: quickCommandFormSchema,
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
wrapperCol: { span: 24 },
|
||||
});
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
await resetFields();
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
if (unref(isUpdate)) {
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...data.record,
|
||||
});
|
||||
}
|
||||
setModalProps({ minHeight: 200, bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
try {
|
||||
let values = await validate();
|
||||
setModalProps({ confirmLoading: true });
|
||||
if(isUpdate.value){
|
||||
emit('update-ok',values);
|
||||
}else{
|
||||
emit('ok', values);
|
||||
}
|
||||
handleCancel();
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
registerForm,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-title {
|
||||
color: #1d2025;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.type-desc {
|
||||
color: #8f959e;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" :width="width" :title="title" :footer="null">
|
||||
<!-- 嵌入表单 -->
|
||||
<div v-if="type === 'menu'">
|
||||
<a-form layout="vertical" :model="appData">
|
||||
<a-form-item label="菜单名称">
|
||||
<a-input v-model:value="appData.name" readonly/>
|
||||
</a-form-item>
|
||||
<a-form-item label="菜单地址">
|
||||
<a-input v-model:value="appData.menu" readonly/>
|
||||
</a-form-item>
|
||||
<a-form-item style="text-align:right">
|
||||
<a-button @click.prevent="copyMenu">复制菜单</a-button>
|
||||
<a-button type="primary" style="margin-left: 10px" @click="copySql">复制SQL</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
<!-- 嵌入网站 -->
|
||||
<div v-else-if="type === 'web'" class="web">
|
||||
|
||||
<div style="display: flex;margin: 0 auto">
|
||||
<div :class="activeKey===1?'active':''" class="web-img" @click="handleImageClick(1)">
|
||||
<img src="../img/webEmbedded.png" />
|
||||
</div>
|
||||
<div style="margin-left: 10px" :class="activeKey===2?'active':''" class="web-img" @click="handleImageClick(2)">
|
||||
<img src="../img/iconWebEmbedded.png" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="web-title" v-if="activeKey === 1">
|
||||
将以下 iframe 嵌入到你的网站中的目标位置
|
||||
</div>
|
||||
<div class="web-title" v-else>
|
||||
将以下 script 添加到网页的body区域中
|
||||
</div>
|
||||
<div class="web-code" v-if="activeKey === 1">
|
||||
<div class="web-code-title">
|
||||
<div class="web-code-desc">
|
||||
html
|
||||
</div>
|
||||
<Icon class="pointer" icon="ant-design:copy-outlined" @click="copyIframe(1)"></Icon>
|
||||
</div>
|
||||
<div class="web-code-iframe">
|
||||
<pre> {{getIframeText(1)}} </pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="web-code" v-if="activeKey === 2">
|
||||
<div class="web-code-title">
|
||||
<div class="web-code-desc">
|
||||
html
|
||||
</div>
|
||||
<Icon class="pointer" icon="ant-design:copy-outlined" @click="copyIframe(2)"></Icon>
|
||||
</div>
|
||||
<div class="web-code-iframe">
|
||||
<pre> {{getIframeText(2)}} </pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModalInner } from '@/components/Modal';
|
||||
|
||||
import BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { buildUUID } from '@/utils/uuid';
|
||||
import { copyTextToClipboard } from '@/hooks/web/useCopyToClipboard';
|
||||
import { isDevMode } from '/@/utils/env';
|
||||
|
||||
export default {
|
||||
name: 'AiAppSendModal',
|
||||
components: {
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
//标题
|
||||
const title = ref<string>('嵌入网站');
|
||||
const $message = useMessage();
|
||||
//类型
|
||||
const type = ref<string>('web');
|
||||
//应用信息
|
||||
const appData = ref<any>({});
|
||||
//弹窗宽度
|
||||
const width = ref<string>("800px");
|
||||
//选中的key
|
||||
const activeKey = ref<number>(1);
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
type.value = data.type;
|
||||
appData.value = data.data;
|
||||
appData.value.menu = "/ai/chat/"+ data.data.id
|
||||
activeKey.value = 1;
|
||||
let minHeight = 220;
|
||||
if(data.type === 'web'){
|
||||
title.value = '嵌入网站';
|
||||
width.value = '640px';
|
||||
minHeight = 500
|
||||
}else{
|
||||
title.value = '配置菜单';
|
||||
width.value = '500px';
|
||||
}
|
||||
setModalProps({ height: minHeight, bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
|
||||
/**
|
||||
* 复制菜单
|
||||
*/
|
||||
function copyMenu() {
|
||||
copyText(appData.value.menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制sql
|
||||
*/
|
||||
function copySql() {
|
||||
const insertMenuSql = `INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
|
||||
VALUES ('${buildUUID()}', NULL, '${appData.value.name}', '${appData.value.menu}', '1', NULL, NULL, 0, NULL, '1', 0.00, 0, NULL, 0, 1, 0, 0, 0, NULL, '1', 0, 0, 'admin', null, NULL, NULL, 0)`;
|
||||
copyText(insertMenuSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前文本
|
||||
*/
|
||||
function getIframeText(value) {
|
||||
let locationUrl = document.location.protocol +"//" + window.location.host;
|
||||
//update-begin---author:wangshuai---date:2025-03-20---for:【QQYUN-11649】【AI】应用嵌入,支持一个小图标点击出聊天---
|
||||
if(value === 1){
|
||||
return '<iframe\n' +
|
||||
' src="'+locationUrl+'/ai/app/chat/'+appData.value.id+'"\n' +
|
||||
' style="width: 100%; height: 100%;">\n' +
|
||||
'</iframe>';
|
||||
}else{
|
||||
//update-begin---author:wangshuai---date:2025-03-28---for:【QQYUN-11649】应用嵌入,支持一个小图标点击出聊天---
|
||||
let path = "/src/views/super/airag/aiapp/chat/js/chat.js"
|
||||
if(!isDevMode()){
|
||||
path = "/chat/chat.js";
|
||||
}
|
||||
let text ='<script src=' + locationUrl + path +' id="e7e007dd52f67fe36365eff636bbffbd">'+'<'+'/script>';
|
||||
text += '\n <'+'script>\n';
|
||||
text += ' createAiChat({\n' +
|
||||
' appId:"'+ appData.value.id +'",\n';
|
||||
text += ' // 支持top-left左上, top-right右上, bottom-left左下, bottom-right右下\n';
|
||||
text += ' iconPosition:"bottom-right"\n';
|
||||
text += ' })\n';
|
||||
text += ' <'+'/script>';
|
||||
return text;
|
||||
//update-end---author:wangshuai---date:2025-03-28---for:【QQYUN-11649】应用嵌入,支持一个小图标点击出聊天---
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-20---for:【QQYUN-11649】【AI】应用嵌入,支持一个小图标点击出聊天---
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制iframe
|
||||
*/
|
||||
function copyIframe(value) {
|
||||
copyText(getIframeText(value));
|
||||
}
|
||||
|
||||
// 复制文本到剪贴板
|
||||
function copyText(text: string) {
|
||||
const success = copyTextToClipboard(text);
|
||||
if (success) {
|
||||
$message.createMessage.success('复制成功!');
|
||||
} else {
|
||||
$message.createMessage.error('复制失败!');
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片点击事件
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleImageClick(value) {
|
||||
activeKey.value = value;
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
title,
|
||||
type,
|
||||
appData,
|
||||
copySql,
|
||||
copyMenu,
|
||||
width,
|
||||
copyIframe,
|
||||
getIframeText,
|
||||
activeKey,
|
||||
handleImageClick,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.type-title {
|
||||
color: #1d2025;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.type-desc {
|
||||
color: #8f959e;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.web{
|
||||
padding: 0 10px;
|
||||
}
|
||||
.web-title{
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
}
|
||||
.web-img{
|
||||
border-width: 1.5px;
|
||||
width: 240px;
|
||||
margin-top: 20px;
|
||||
border-radius: 6px;
|
||||
img{
|
||||
border-radius: 6px;
|
||||
width: 240px;
|
||||
height: 150px;
|
||||
}
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.active{
|
||||
border-color: rgb(41 112 255);
|
||||
}
|
||||
.web-code{
|
||||
border-width: 1.5px;
|
||||
margin-top: 20px;
|
||||
background-color: #f9fafb;
|
||||
border-color: #10182814;
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
.web-code-title{
|
||||
width: 100%;
|
||||
padding:10px;
|
||||
background-color: #f2f4f7;
|
||||
display: inline-flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.web-code-desc{
|
||||
color: #354052;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
}
|
||||
.web-code-iframe{
|
||||
padding: 15px;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #354052;
|
||||
}
|
||||
}
|
||||
.pointer{
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<BasicModal v-bind="$attrs" @register="registerModal" title="用户变量" :width="1000" @ok="handleSubmit" destroyOnClose>
|
||||
<div class="p-4">
|
||||
<div class="mb-4 text-gray-500"> 用于存储每个用户使用项目过程中,需要持久化存储和读取的数据,如用户的语言偏好、个性化设置等。 </div>
|
||||
|
||||
<JVxeTable
|
||||
ref="tableRef"
|
||||
toolbar
|
||||
dragSort
|
||||
:maxHeight="500"
|
||||
:columns="columns"
|
||||
:dataSource="dataSource"
|
||||
:toolbarConfig="{ btns: ['remove', 'clearSelection'] }"
|
||||
>
|
||||
<template #toolbarSuffix>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<Icon icon="ant-design:plus-outlined" />
|
||||
新增
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<template #action="props">
|
||||
<div class="action-group">
|
||||
<a-switch v-model:checked="props.row.enable" checked-children="开" un-checked-children="关" size="small" class="ml-2" />
|
||||
|
||||
<a-popconfirm title="确定删除吗?" @confirm="handleDelete(props)">
|
||||
<Icon icon="ant-design:delete-outlined" class="cursor-pointer hover:text-red-500 ml-2" size="18" color="red" />
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</JVxeTable>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, reactive } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/src/components/Modal';
|
||||
import { Icon } from '/src/components/Icon';
|
||||
import { Button, Checkbox, Switch, Popconfirm } from 'ant-design-vue';
|
||||
import { JVxeTypes, JVxeColumn, JVxeTableInstance } from '/src/components/jeecg/JVxeTable/types';
|
||||
import { JVxeTable } from '/src/components/jeecg/JVxeTable';
|
||||
import { useMessage } from '/src/hooks/web/useMessage';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AiUserVariablesModal',
|
||||
components: {
|
||||
BasicModal,
|
||||
Icon,
|
||||
JVxeTable,
|
||||
AButton: Button,
|
||||
ACheckbox: Checkbox,
|
||||
ASwitch: Switch,
|
||||
APopconfirm: Popconfirm,
|
||||
},
|
||||
emits: ['register', 'ok'],
|
||||
setup(props, { emit }) {
|
||||
const { createMessage } = useMessage();
|
||||
const tableRef = ref<JVxeTableInstance>();
|
||||
|
||||
// 定义列配置
|
||||
const columns = ref<JVxeColumn[]>([
|
||||
{
|
||||
title: '名称',
|
||||
key: 'name',
|
||||
type: JVxeTypes.input,
|
||||
width: 200,
|
||||
placeholder: '请输入变量名',
|
||||
validateRules: [
|
||||
{ required: true, message: '名称不能为空' },
|
||||
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/, message: '仅支持字母、数字和下划线,且以字母开头' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
key: 'description',
|
||||
type: JVxeTypes.input,
|
||||
width: 300,
|
||||
placeholder: '字段描述',
|
||||
},
|
||||
{
|
||||
title: '默认值',
|
||||
key: 'defaultValue',
|
||||
type: JVxeTypes.input,
|
||||
width: 200,
|
||||
placeholder: '默认值',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
type: JVxeTypes.slot,
|
||||
slotName: 'action',
|
||||
width: 220,
|
||||
align: 'center',
|
||||
},
|
||||
]);
|
||||
|
||||
const dataSource = ref<any[]>([]);
|
||||
|
||||
const [registerModal, { setModalProps, closeModal }] = useModalInner((data) => {
|
||||
// 如果传入了已有数据,进行初始化
|
||||
if (data && data.variables) {
|
||||
dataSource.value = JSON.parse(data.variables);
|
||||
} else {
|
||||
dataSource.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
// 新增行
|
||||
const handleAdd = () => {
|
||||
if (dataSource.value.length > 9) {
|
||||
createMessage.warn('最多支持10个变量!');
|
||||
return;
|
||||
}
|
||||
tableRef.value?.addRows({
|
||||
name: '',
|
||||
description: '',
|
||||
defaultValue: '',
|
||||
enable: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 删除行
|
||||
const handleDelete = (props) => {
|
||||
tableRef.value?.removeRows(props.row);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 校验表格
|
||||
const errMap = await tableRef.value?.validateTable();
|
||||
if (errMap) {
|
||||
createMessage.error('请检查表单填写是否正确');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取全量数据
|
||||
const tableData = tableRef.value?.getTableData();
|
||||
console.log('保存用户变量:', tableData);
|
||||
|
||||
closeModal();
|
||||
emit('ok', tableData);
|
||||
};
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
tableRef,
|
||||
columns,
|
||||
dataSource,
|
||||
handleAdd,
|
||||
handleDelete,
|
||||
handleSubmit,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.text-gray-500 {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hover\:text-red-500:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
BIN
jeecgboot-vue3/src/views/super/airag/aiapp/img/ailogo.png
Normal file
BIN
jeecgboot-vue3/src/views/super/airag/aiapp/img/ailogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
jeecgboot-vue3/src/views/super/airag/aiapp/img/webEmbedded.png
Normal file
BIN
jeecgboot-vue3/src/views/super/airag/aiapp/img/webEmbedded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Reference in New Issue
Block a user