第一次提交
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
@@ -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
@@ -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
@@ -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
@@ -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
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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 29 KiB |
BIN
jeecgboot-vue3/src/views/super/airag/aiapp/img/webEmbedded.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,137 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
enum Api {
|
||||
//知识库管理
|
||||
list = '/airag/knowledge/list',
|
||||
save = '/airag/knowledge/add',
|
||||
delete = '/airag/knowledge/delete',
|
||||
queryById = '/airag/knowledge/queryById',
|
||||
edit = '/airag/knowledge/edit',
|
||||
rebuild = '/airag/knowledge/rebuild',
|
||||
//知识库文档
|
||||
knowledgeDocList = '/airag/knowledge/doc/list',
|
||||
knowledgeEditDoc = '/airag/knowledge/doc/edit',
|
||||
knowledgeDeleteBatchDoc = '/airag/knowledge/doc/deleteBatch',
|
||||
knowledgeDeleteAllDoc = '/airag/knowledge/doc/deleteAll',
|
||||
knowledgeRebuildDoc = '/airag/knowledge/doc/rebuild',
|
||||
knowledgeEmbeddingHitTest = '/airag/knowledge/embedding/hitTest',
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询知识库
|
||||
* @param params
|
||||
*/
|
||||
export const list = (params) => {
|
||||
return defHttp.get({ url: Api.list, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据id查询知识库
|
||||
* @param params
|
||||
*/
|
||||
export const queryById = (params) => {
|
||||
return defHttp.get({ url: Api.queryById, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增知识库
|
||||
* @param params
|
||||
*/
|
||||
export const saveKnowledge = (params) => {
|
||||
return defHttp.post({ url: Api.save, params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 编辑知识库
|
||||
*
|
||||
* @param params
|
||||
*/
|
||||
export const editKnowledge = (params) => {
|
||||
return defHttp.put({ url: Api.edit, params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除知识库
|
||||
*/
|
||||
export const deleteModel = (params, handleSuccess) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '是否删除名称为'+params.name+'的知识库吗?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
return defHttp.delete({ url: Api.delete, params }, { joinParamsToUrl: true }).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询知识库详情
|
||||
* @param params
|
||||
*/
|
||||
export const knowledgeDocList = (params) => {
|
||||
return defHttp.get({ url: Api.knowledgeDocList, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 知识库向量化
|
||||
*
|
||||
* @param params
|
||||
*/
|
||||
export const rebuild = (params) => {
|
||||
return defHttp.put({ url: Api.rebuild, params,timeout: 2 * 60 * 1000 }, { joinParamsToUrl: true, isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增知识库
|
||||
* @param params
|
||||
*/
|
||||
export const knowledgeSaveDoc = (params) => {
|
||||
return defHttp.post({ url: Api.knowledgeEditDoc, params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 文档向量化
|
||||
* @param params
|
||||
*/
|
||||
export const knowledgeRebuildDoc = (params, handleSuccess) => {
|
||||
return defHttp.put({ url: Api.knowledgeRebuildDoc, params }, { joinParamsToUrl: true }).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量删除文档
|
||||
*
|
||||
* @param params
|
||||
* @param handleSuccess
|
||||
*/
|
||||
export const knowledgeDeleteBatchDoc = (params, handleSuccess) => {
|
||||
return defHttp.delete({ url: Api.knowledgeDeleteBatchDoc, params }, { joinParamsToUrl: true }).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量删除文档
|
||||
*
|
||||
* @param params
|
||||
* @param handleSuccess
|
||||
*/
|
||||
export const knowledgeDeleteAllDoc = (knowId: string, handleSuccess) => {
|
||||
return defHttp.delete({ url: Api.knowledgeDeleteAllDoc, params: {knowId} }, { joinParamsToUrl: true }).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 命中测试
|
||||
* @param params
|
||||
*/
|
||||
export const knowledgeEmbeddingHitTest = (params) => {
|
||||
let url = Api.knowledgeEmbeddingHitTest + '/' + params.knowId;
|
||||
return defHttp.get({ url: url, params }, { isTransformResponse: false });
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import {knowledgeDeleteAllDoc} from "./AiKnowledgeBase.api";
|
||||
import {useMessage} from "@/hooks/web/useMessage";
|
||||
|
||||
const {createConfirmSync} = useMessage();
|
||||
|
||||
// 清空文档
|
||||
export async function doDeleteAllDoc(knowledgeId: string, reload: () => void) {
|
||||
const flag = await createConfirmSync({
|
||||
title: '清空文档',
|
||||
content: () => (
|
||||
<p>
|
||||
<span>确定要清空所有文档吗?</span>
|
||||
<br/>
|
||||
<span style="color: #ee0000;">
|
||||
此操作会删除所有已录入的文档,并且不能恢复,请谨慎操作
|
||||
</span>
|
||||
</p>
|
||||
),
|
||||
});
|
||||
if (!flag) {
|
||||
return;
|
||||
}
|
||||
knowledgeDeleteAllDoc(knowledgeId, reload)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { BasicColumn } from '@/components/Table';
|
||||
|
||||
/**
|
||||
* 表单
|
||||
*/
|
||||
export const formSchema: FormSchema[] = [
|
||||
{
|
||||
label: 'id',
|
||||
field: 'id',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: '知识库名称',
|
||||
field: 'name',
|
||||
required: true,
|
||||
componentProps: {
|
||||
placeholder: '请输入知识库名称',
|
||||
//是否展示字数
|
||||
showCount: true,
|
||||
maxlength: 64,
|
||||
},
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
label: '知识库描述',
|
||||
field: 'descr',
|
||||
component: 'InputTextArea',
|
||||
componentProps: {
|
||||
placeholder: '描述知识库的内容,详尽的描述将帮助AI能深入理解该知识库的内容,能更准确的检索到内容,提高该知识库的命中率。',
|
||||
//是否展示字数
|
||||
showCount: true,
|
||||
maxlength: 256,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '向量模型',
|
||||
field: 'embedId',
|
||||
required: true,
|
||||
component: 'JDictSelectTag',
|
||||
componentProps: {
|
||||
dictCode: "airag_model where model_type = 'EMBED' and activate_flag = 1,name,id",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
field: 'status',
|
||||
required: true,
|
||||
component: 'JDictSelectTag',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '启用', value: 'enable' },
|
||||
{ label: '禁用', value: 'disable' },
|
||||
],
|
||||
type: 'radioButton',
|
||||
},
|
||||
defaultValue: 'enable',
|
||||
},
|
||||
{
|
||||
label: '类型',
|
||||
field: 'type',
|
||||
required: true,
|
||||
component: 'JDictSelectTag',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '知识库', value: 'knowledge' },
|
||||
{ label: '记忆库', value: 'memory' },
|
||||
],
|
||||
type: 'radioButton',
|
||||
},
|
||||
defaultValue: 'knowledge',
|
||||
},
|
||||
];
|
||||
|
||||
//文档文本表单
|
||||
export const docTextSchema: FormSchema[] = [
|
||||
{
|
||||
label: 'id',
|
||||
field: 'id',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: '知识库id',
|
||||
field: 'knowledgeId',
|
||||
show: false,
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
label: '标题',
|
||||
field: 'title',
|
||||
required: true,
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
label: '类型',
|
||||
field: 'type',
|
||||
required: true,
|
||||
component: 'Input',
|
||||
show: false
|
||||
},
|
||||
{
|
||||
label: '内容',
|
||||
field: 'content',
|
||||
rules: [{ required: true, message: '请输入内容' }],
|
||||
component: 'JMarkdownEditor',
|
||||
componentProps: {
|
||||
placeholder: "请输入内容",
|
||||
preview:{ mode: 'view', action: [] }
|
||||
},
|
||||
ifShow:({ values})=>{
|
||||
if(values.type === 'text'){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '文件',
|
||||
field: 'filePath',
|
||||
rules: [{ required: true, message: '请上传文件' }],
|
||||
component: 'JUpload',
|
||||
helpMessage:'支持txt、markdown、pdf、docx、xlsx、pptx',
|
||||
componentProps:{
|
||||
fileType: 'file',
|
||||
maxCount: 1,
|
||||
multiple: false,
|
||||
text: '上传文档'
|
||||
},
|
||||
ifShow:({ values })=>{
|
||||
if(values.type === 'file'){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
<!--知识库添加页面-->
|
||||
<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="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="handleAddKnowled">
|
||||
<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 v-if="knowledgeList && knowledgeList.length>0" :xxl="4" :xl="6" :lg="6" :md="6" :sm="12" :xs="24" v-for="item in knowledgeList">
|
||||
<a-card class="knowledge-card pointer" @click="handleDocClick(item.id, item.type)">
|
||||
<div class="knowledge-header">
|
||||
<div class="flex">
|
||||
<img class="header-img" src="./icon/knowledge.png" />
|
||||
<div class="header-text">
|
||||
<span class="header-text-top header-name ellipsis" :title="item.name"> {{ item.name }} </span>
|
||||
<span class="header-text-top"> 创建者:{{ item.createBy_dictText || item.createBy }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-10 text-desc">
|
||||
<span>{{ item.descr || '暂无描述' }}</span>
|
||||
</div>
|
||||
<div class="knowledge-footer flex" style="justify-content: space-between">
|
||||
<div style="width: calc(100% - 60px)" class="ellipsis">
|
||||
<Icon class="knowledge-footer-icon" icon="ant-design:deployment-unit-outlined" size="14"></Icon>
|
||||
<span :title="item.embedId_dictText">{{ item.embedId_dictText }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<a-tag v-if="item.type == 'memory'" color="orange" class="tag-style">记忆库</a-tag>
|
||||
<a-tag v-else color="blue" class="tag-style">知识库</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="knowledge-btn">
|
||||
<a-dropdown placement="bottomRight" :trigger="['click']" :getPopupContainer="(node) => node.parentNode">
|
||||
<div class="ant-dropdown-link pointer model-icon" @click.prevent.stop>
|
||||
<Icon icon="ant-design:ellipsis-outlined" size="16"></Icon>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="vectorization" @click.prevent.stop="handleVectorization(item.id)">
|
||||
<Icon icon="ant-design:retweet-outlined" size="16"></Icon>
|
||||
向量化
|
||||
</a-menu-item>
|
||||
<a-menu-item key="text" @click.prevent.stop="handleEditClick(item)">
|
||||
<Icon class="pointer" icon="ant-design:edit-outlined" size="16"></Icon>
|
||||
编辑
|
||||
</a-menu-item>
|
||||
<a-menu-item key="file" @click.prevent.stop="handleDelete(item)">
|
||||
<Icon class="pointer" icon="ant-design:delete-outlined" size="16"></Icon>
|
||||
删除
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clear" @click.prevent.stop="onDeleteAllDoc(item)">
|
||||
<Icon icon="ant-design:delete-outlined" size="16"></Icon>
|
||||
清空文档
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<Pagination
|
||||
v-if="knowledgeList.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}条` "
|
||||
/>
|
||||
<!--添加知识库弹窗-->
|
||||
<KnowledgeBaseModal @register="registerModal" @success="reload"></KnowledgeBaseModal>
|
||||
<!-- 知识库文档弹窗 -->
|
||||
<AiragKnowledgeDocListModal @register="docListRegister"></AiragKnowledgeDocListModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { deleteModel, list, rebuild } from './AiKnowledgeBase.api';
|
||||
import { doDeleteAllDoc } from "./AiKnowledgeBase.api.util";
|
||||
import { Pagination } from 'ant-design-vue';
|
||||
import JInput from '@/components/Form/src/jeecg/components/JInput.vue';
|
||||
import KnowledgeBaseModal from './components/AiKnowledgeBaseModal.vue';
|
||||
import JSelectUser from '@/components/Form/src/jeecg/components/JSelectUser.vue';
|
||||
import JDictSelectTag from '@/components/Form/src/jeecg/components/JDictSelectTag.vue';
|
||||
import AiragKnowledgeDocListModal from './components/AiragKnowledgeDocListModal.vue';
|
||||
import Icon from '@/components/Icon';
|
||||
import { useMessage } from "@/hooks/web/useMessage";
|
||||
|
||||
export default {
|
||||
name: 'KnowledgeBaseList',
|
||||
components: {
|
||||
Icon,
|
||||
AiragKnowledgeDocListModal,
|
||||
KnowledgeBaseModal,
|
||||
JDictSelectTag,
|
||||
JSelectUser,
|
||||
JInput,
|
||||
Pagination,
|
||||
},
|
||||
setup() {
|
||||
//模型列表
|
||||
const knowledgeList = ref([]);
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
const [docListRegister, { openModal: openDocModal }] = useModal();
|
||||
|
||||
//当前页数
|
||||
const pageNo = ref<number>(1);
|
||||
//每页条数
|
||||
const pageSize = ref<number>(10);
|
||||
//总条数
|
||||
const total = ref<number>(0);
|
||||
//可选择的页数
|
||||
const pageSizeOptions = ref<any>(['10', '20', '30']);
|
||||
//查询参数
|
||||
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();
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
//页面初始化执行列表查询
|
||||
reload();
|
||||
|
||||
/**
|
||||
* 新增
|
||||
*/
|
||||
async function handleAddKnowled() {
|
||||
openModal(true, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*
|
||||
* @param item
|
||||
*/
|
||||
function handleEditClick(item) {
|
||||
console.log(item);
|
||||
openModal(true, {
|
||||
id: item.id,
|
||||
isUpdate: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载数据
|
||||
*/
|
||||
function reload() {
|
||||
let params = {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
column: 'createTime',
|
||||
order: 'desc'
|
||||
};
|
||||
Object.assign(params, queryParam);
|
||||
|
||||
list(params).then((res) => {
|
||||
if (res.success) {
|
||||
knowledgeList.value = res.result.records;
|
||||
total.value = res.result.total;
|
||||
} else {
|
||||
knowledgeList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页改变事件
|
||||
* @param page
|
||||
* @param current
|
||||
*/
|
||||
function handlePageChange(page, current) {
|
||||
pageNo.value = page;
|
||||
pageSize.value = current;
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模型
|
||||
* @param item
|
||||
*/
|
||||
async function handleDelete(item) {
|
||||
if(knowledgeList.value.length == 1 && pageNo.value > 1) {
|
||||
pageNo.value = pageNo.value - 1;
|
||||
}
|
||||
await deleteModel({ id: item.id, name: item.name }, reload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空文档
|
||||
* @param item
|
||||
*/
|
||||
async function onDeleteAllDoc(item) {
|
||||
pageNo.value = 1;
|
||||
return doDeleteAllDoc(item.id, reload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
function searchQuery() {
|
||||
pageNo.value = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function searchReset() {
|
||||
formRef.value.resetFields();
|
||||
queryParam.createBy = '';
|
||||
pageNo.value = 1;
|
||||
//刷新数据
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数配置点击事件
|
||||
*
|
||||
* @param id
|
||||
* @param type
|
||||
*/
|
||||
function handleDocClick(id, type) {
|
||||
openDocModal(true, { id, type });
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库向量化
|
||||
* @param id
|
||||
*/
|
||||
async function handleVectorization(id) {
|
||||
rebuild({ knowIds: id }).then((res) =>{
|
||||
if(res.success){
|
||||
createMessage.success("操作成功,开始异步重建知识库,请稍后查看!");
|
||||
reload();
|
||||
}else{
|
||||
createMessage.warning("向量化失败!");
|
||||
}
|
||||
}).catch(err=>{
|
||||
createMessage.warning("向量化失败!");
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
handleAddKnowled,
|
||||
handleEditClick,
|
||||
registerModal,
|
||||
knowledgeList,
|
||||
reload,
|
||||
pageNo,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
total,
|
||||
handlePageChange,
|
||||
handleDelete,
|
||||
onDeleteAllDoc,
|
||||
searchQuery,
|
||||
searchReset,
|
||||
queryParam,
|
||||
labelCol,
|
||||
wrapperCol,
|
||||
formRef,
|
||||
handleDocClick,
|
||||
docListRegister,
|
||||
handleVectorization,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.knowledge {
|
||||
height: calc(100vh - 115px);
|
||||
background: #f7f8fc;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
|
||||
.knowledge-row {
|
||||
max-height: calc(100% - 100px);
|
||||
margin-top: 20px;
|
||||
overflow-y: auto;
|
||||
.knowledge-header {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
.header-img {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.header-text {
|
||||
width: calc(100% - 80px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: grid;
|
||||
.header-name {
|
||||
font-size: 14px !important;
|
||||
font-weight: bold;
|
||||
color: #354052 !important;
|
||||
}
|
||||
.header-text-top {
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-desc {
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: #676f83;
|
||||
}
|
||||
|
||||
.knowledge-footer{
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 16px;
|
||||
.knowledge-footer-icon{
|
||||
position: relative;
|
||||
top: 2px
|
||||
}
|
||||
span{
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.ant-card .ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mt-10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.knowledge-btn {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 6px;
|
||||
height: auto;
|
||||
display: none;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.add-knowledge-card:hover {
|
||||
box-shadow: 0 6px 12px #d0d3d8;
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 20px;
|
||||
height: 152px;
|
||||
border-radius: 10px;
|
||||
background: #fcfcfd;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 4px #e6e6e6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.knowledge-card:hover {
|
||||
box-shadow: 0 6px 12px #d0d3d8;
|
||||
.knowledge-btn {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-footer {
|
||||
text-align: right;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.model-icon{
|
||||
background-color: unset;
|
||||
border: none;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.model-icon:hover{
|
||||
color: #000000;
|
||||
background-color: #e9ecf2;
|
||||
border: none;
|
||||
}
|
||||
.ant-dropdown-link{
|
||||
font-size: 14px;
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ellipsis{
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-style{
|
||||
margin-right: 5px;
|
||||
height: 20px;
|
||||
line-height: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" width="600px" :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/knowledge" 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"></BasicForm>
|
||||
</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 BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { useForm } from '@/components/Form';
|
||||
import { formSchema } from '../AiKnowledgeBase.data';
|
||||
import { saveKnowledge, editKnowledge, queryById } from '../AiKnowledgeBase.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
|
||||
export default {
|
||||
name: 'KnowledgeBaseModal',
|
||||
components: {
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
const title = ref<string>('创建知识库');
|
||||
|
||||
//保存或修改
|
||||
const isUpdate = ref<boolean>(false);
|
||||
|
||||
//表单配置
|
||||
const [registerForm, { resetFields, setFieldsValue, validate, clearValidate, updateSchema }] = useForm({
|
||||
schemas: formSchema,
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
wrapperCol: { span: 24 },
|
||||
});
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
//重置表单
|
||||
await resetFields();
|
||||
setModalProps({ confirmLoading: false });
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
title.value = isUpdate.value ? '编辑知识库' : '创建知识库';
|
||||
if (unref(isUpdate)) {
|
||||
let values = await queryById({ id: data.id });
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...values.result,
|
||||
});
|
||||
}
|
||||
setModalProps({ minHeight: 500, bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
try {
|
||||
setModalProps({ confirmLoading: true });
|
||||
let values = await validate();
|
||||
if (!unref(isUpdate)) {
|
||||
await saveKnowledge(values);
|
||||
} else {
|
||||
await editKnowledge(values);
|
||||
}
|
||||
//关闭弹窗
|
||||
closeModal();
|
||||
//刷新列表
|
||||
emit('success');
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
registerForm,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<!--手动录入text-->
|
||||
<template>
|
||||
<BasicModal title="段落详情" destroyOnClose @register="registerModal" :canFullscreen="false" width="600px" :footer="null">
|
||||
<div class="p-2">
|
||||
<div class="header">
|
||||
<a-tag color="#a9c8ff">
|
||||
<span>{{hitTextDescData.source}}</span>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="content">
|
||||
<MarkdownViewer :value="hitTextDescData.content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 { useGlobSetting } from "@/hooks/setting";
|
||||
|
||||
export default {
|
||||
name: 'AiTextDescModal',
|
||||
components: {
|
||||
MarkdownViewer,
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
let hitTextDescData = ref<any>({})
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
hitTextDescData.value.source = 'score' + ' ' + data.score.toFixed(2);
|
||||
//替换图片宽度
|
||||
data.content = replaceImageWith(data.content);
|
||||
//替换图片domainUrl
|
||||
data.content = replaceDomainUrl(data.content);
|
||||
hitTextDescData.value.content = data.content;
|
||||
setModalProps({ header: '300px' })
|
||||
});
|
||||
const { domainUrl } = useGlobSetting();
|
||||
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 `<img src='${src}' alt='${alt}' width='${width}' />`;
|
||||
});
|
||||
};
|
||||
|
||||
//替换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);
|
||||
})
|
||||
}
|
||||
return {
|
||||
registerModal,
|
||||
hitTextDescData
|
||||
};
|
||||
},
|
||||
};
|
||||
</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,111 @@
|
||||
<!--手动录入text-->
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" width="600px" :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 { useModal, useModalInner } from '@/components/Modal';
|
||||
|
||||
import BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { useForm } from '@/components/Form';
|
||||
import { docTextSchema } from '../AiKnowledgeBase.data';
|
||||
import { knowledgeSaveDoc, queryById } from '../AiKnowledgeBase.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
|
||||
export default {
|
||||
name: 'AiragKnowledgeDocModal',
|
||||
components: {
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
const title = ref<string>('创建知识库');
|
||||
|
||||
//保存或修改
|
||||
const isUpdate = ref<boolean>(false);
|
||||
//知识库id
|
||||
const knowledgeId = ref<string>();
|
||||
//表单配置
|
||||
const [registerForm, { resetFields, setFieldsValue, validate, clearValidate, updateSchema }] = useForm({
|
||||
schemas: docTextSchema,
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
wrapperCol: { span: 24 },
|
||||
});
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
//重置表单
|
||||
await resetFields();
|
||||
setModalProps({ confirmLoading: false });
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
title.value = isUpdate.value ? '编辑文档' : '创建文档';
|
||||
if (unref(isUpdate)) {
|
||||
if(data.record.type === 'file' && data.record.metadata){
|
||||
data.record.filePath = JSON.parse(data.record.metadata).filePath;
|
||||
}
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...data.record,
|
||||
});
|
||||
} else {
|
||||
knowledgeId.value = data.knowledgeId;
|
||||
await setFieldsValue({ type: data.type })
|
||||
}
|
||||
setModalProps({ bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
try {
|
||||
setModalProps({ confirmLoading: true });
|
||||
let values = await validate();
|
||||
if (!unref(isUpdate)) {
|
||||
values.knowledgeId = knowledgeId.value;
|
||||
}
|
||||
if(values.filePath){
|
||||
values.metadata = JSON.stringify({ filePath: values.filePath });
|
||||
delete values.filePath;
|
||||
}
|
||||
await knowledgeSaveDoc(values);
|
||||
//关闭弹窗
|
||||
closeModal();
|
||||
//刷新列表
|
||||
emit('success');
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
registerForm,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
BIN
jeecgboot-vue3/src/views/super/airag/aiknowledge/icon/draft.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
87
jeecgboot-vue3/src/views/super/airag/aimcp/AiragMcp.api.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
// import { useMessage } from "/@/hooks/web/useMessage"; // 需要确认弹窗再启用
|
||||
|
||||
enum Api {
|
||||
list = '/airag/airagMcp/list',
|
||||
save='/airag/airagMcp/save',
|
||||
deleteOne = '/airag/airagMcp/delete',
|
||||
importExcel = '/airag/airagMcp/importExcel',
|
||||
exportXls = '/airag/airagMcp/exportXls',
|
||||
sync = '/airag/airagMcp/sync',
|
||||
toggleStatus = '/airag/airagMcp/status',
|
||||
saveAndSync = '/airag/airagMcp/saveAndSync',
|
||||
queryById = '/airag/airagMcp/queryById',
|
||||
saveTools = '/airag/airagMcp/saveTools',
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出api
|
||||
* @param params
|
||||
*/
|
||||
export const getExportUrl = Api.exportXls;
|
||||
|
||||
/**
|
||||
* 导入api
|
||||
*/
|
||||
export const getImportUrl = Api.importExcel;
|
||||
|
||||
/**
|
||||
* 列表接口
|
||||
* @param params
|
||||
*/
|
||||
export const list = (params) => defHttp.get({ url: Api.list, params });
|
||||
|
||||
/**
|
||||
* 删除单个
|
||||
* @param params
|
||||
* @param handleSuccess
|
||||
*/
|
||||
export const deleteOne = (params,handleSuccess) => {
|
||||
return defHttp.delete({url: Api.deleteOne, params}, {joinParamsToUrl: true}).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 保存或者更新
|
||||
* @param params
|
||||
* @param isUpdate
|
||||
*/
|
||||
export const saveOrUpdate = (params) => {
|
||||
return defHttp.post({url: Api.save, data: params}, { isTransformResponse: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存并同步
|
||||
* @param params
|
||||
* @param isUpdate
|
||||
*/
|
||||
export const saveAndSync = (params) => {
|
||||
return defHttp.post({url: Api.saveAndSync, data: params}, { isTransformResponse: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步接口
|
||||
* @param id
|
||||
*/
|
||||
export const syncMcp = (id) => defHttp.post({ url: Api.sync+"/"+id });
|
||||
|
||||
/**
|
||||
* 修改状态
|
||||
* @param id
|
||||
*/
|
||||
export const toggleStatus = (id,status) => defHttp.post({ url: Api.toggleStatus+"/"+id + "/"+ status });
|
||||
|
||||
/**
|
||||
* 详情查询
|
||||
* @param id
|
||||
*/
|
||||
export const queryById = (id) => defHttp.get({ url: Api.queryById ,params: { id:id }}, { isTransformResponse: false });
|
||||
|
||||
/**
|
||||
* 保存插件工具(仅更新tools字段)
|
||||
* @param id 插件ID
|
||||
* @param tools 工具列表JSON字符串
|
||||
*/
|
||||
export const saveTools = (id, tools) => defHttp.post({ url: Api.saveTools, data: { id, tools } }, { isTransformResponse: false });
|
||||
78
jeecgboot-vue3/src/views/super/airag/aimcp/AiragMcp.data.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {BasicColumn} from '/@/components/Table';
|
||||
import {FormSchema} from '/@/components/Table';
|
||||
import { rules} from '/@/utils/helper/validator';
|
||||
import { render } from '/@/utils/common/renderUtils';
|
||||
import { getWeekMonthQuarterYear } from '/@/utils';
|
||||
//列表数据
|
||||
export const columns: BasicColumn[] = [
|
||||
{
|
||||
title: '图标',
|
||||
align: "center",
|
||||
dataIndex: 'icon'
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
align: "center",
|
||||
dataIndex: 'name'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
align: "center",
|
||||
dataIndex: 'descr'
|
||||
},
|
||||
{
|
||||
title: 'mcp类型(sse:sse类型;stdio:标准类型)',
|
||||
align: "center",
|
||||
dataIndex: 'type'
|
||||
},
|
||||
{
|
||||
title: '服务端点(SSE类型为URL,stdio类型为命令)',
|
||||
align: "center",
|
||||
dataIndex: 'endpoint'
|
||||
},
|
||||
{
|
||||
title: '请求头(sse类型)、环境变量(stdio类型)',
|
||||
align: "center",
|
||||
dataIndex: 'headers'
|
||||
},
|
||||
{
|
||||
title: '工具列表',
|
||||
align: "center",
|
||||
dataIndex: 'tools'
|
||||
},
|
||||
{
|
||||
title: '状态(enable=启用、disable=禁用)',
|
||||
align: "center",
|
||||
dataIndex: 'status'
|
||||
},
|
||||
{
|
||||
title: '是否同步',
|
||||
align: "center",
|
||||
dataIndex: 'synced'
|
||||
},
|
||||
{
|
||||
title: '元数据',
|
||||
align: "center",
|
||||
dataIndex: 'metadata'
|
||||
},
|
||||
{
|
||||
title: '租户id',
|
||||
align: "center",
|
||||
dataIndex: 'tenantId'
|
||||
},
|
||||
];
|
||||
|
||||
// 高级查询数据
|
||||
export const superQuerySchema = {
|
||||
icon: {title: '应用图标',order: 0,view: 'text', type: 'string',},
|
||||
name: {title: '名称',order: 1,view: 'text', type: 'string',},
|
||||
descr: {title: '描述',order: 2,view: 'text', type: 'string',},
|
||||
type: {title: 'mcp类型(sse:sse类型;stdio:标准类型)',order: 3,view: 'text', type: 'string',},
|
||||
endpoint: {title: '服务端点(SSE类型为URL,stdio类型为命令)',order: 4,view: 'textarea', type: 'string',},
|
||||
headers: {title: '请求头(sse类型)、环境变量(stdio类型)',order: 5,view: 'textarea', type: 'string',},
|
||||
tools: {title: '工具列表',order: 6,view: 'textarea', type: 'string',},
|
||||
status: {title: '状态(enable=启用、disable=禁用)',order: 7,view: 'text', type: 'string',},
|
||||
synced: {title: '是否同步',order: 8,view: 'number', type: 'number',},
|
||||
metadata: {title: '元数据',order: 9,view: 'textarea', type: 'string',},
|
||||
tenantId: {title: '租户id',order: 10,view: 'text', type: 'string',},
|
||||
};
|
||||
544
jeecgboot-vue3/src/views/super/airag/aimcp/AiragMcpList.vue
Normal file
@@ -0,0 +1,544 @@
|
||||
<template>
|
||||
<div class="mcp">
|
||||
<!-- 查询区域 -->
|
||||
<div class="jeecg-basic-table-form-container">
|
||||
<a-form
|
||||
ref="formRef"
|
||||
@keyup.enter="searchQuery"
|
||||
:model="queryParam"
|
||||
:label-col="labelCol"
|
||||
:wrapper-col="wrapperCol"
|
||||
style="background-color: #f7f8fc !important;"
|
||||
>
|
||||
<a-row :gutter="24">
|
||||
<a-col :lg="6">
|
||||
<a-form-item name="name" label="名称">
|
||||
<JInput v-model:value="queryParam.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :lg="6">
|
||||
<a-form-item name="category" label="类型">
|
||||
<a-select v-model:value="queryParam.category" placeholder="全部" allowClear>
|
||||
<a-select-option value="plugin">插件</a-select-option>
|
||||
<a-select-option value="mcp">MCP</a-select-option>
|
||||
</a-select>
|
||||
</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="mcp-row">
|
||||
<a-col :xxl="4" :xl="6" :lg="6" :md="6" :sm="12" :xs="24">
|
||||
<a-card class="add-mcp-card" @click="handleAdd" >
|
||||
<div class="flex">
|
||||
<Icon icon="ant-design:plus-outlined" class="add-mcp-card-icon" size="20" />
|
||||
<span class="add-mcp-card-title">新增MCP</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col v-for="item in mcpList" :xxl="4" :xl="6" :lg="6" :md="6" :sm="12" :xs="24" :key="item.id" v-if="mcpList && mcpList.length > 0">
|
||||
<a-card class="mcp-card" @click="handleDetailClick(item)">
|
||||
<div class="mcp-header">
|
||||
<div class="flex">
|
||||
<img :src="getIcon(item.icon)" class="header-img" />
|
||||
<div class="header-text" :title="item.name">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<ul>
|
||||
<li class="flex mr-14">
|
||||
<span class="described" :title="item.descr">{{ item.descr || '-' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mcp-btn">
|
||||
<a-button class="mcp-icon" size="small" @click.prevent.stop="handleEditClick(item)" v-auth="'llm:airag_mcp:edit'">
|
||||
<Icon icon="ant-design:edit-outlined" />
|
||||
</a-button>
|
||||
<a-dropdown placement="bottomRight" :trigger="['click']" :getPopupContainer="(node) => node.parentNode">
|
||||
<div class="ant-dropdown-link pointer mcp-icon" @click.prevent.stop>
|
||||
<Icon icon="ant-design:ellipsis-outlined" />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<!-- MCP类型:显示同步按钮 -->
|
||||
<a-menu-item
|
||||
v-if="item.category === 'mcp'"
|
||||
key="sync"
|
||||
@click.prevent.stop="handleSync(item)"
|
||||
v-auth="'llm:airag_mcp:sync'"
|
||||
>
|
||||
<Icon icon="ant-design:cloud-sync-outlined" size="16" /> 同步
|
||||
</a-menu-item>
|
||||
<!-- 插件类型:显示工具管理按钮 -->
|
||||
<a-menu-item
|
||||
v-if="item.category === 'plugin'"
|
||||
key="toolManage"
|
||||
@click.prevent.stop="handleToolManage(item)"
|
||||
v-auth="'llm:airag_mcp:edit'"
|
||||
>
|
||||
<Icon icon="ant-design:tool-outlined" size="16" /> 工具管理
|
||||
</a-menu-item>
|
||||
<!-- 编辑:始终显示,不受禁用启用影响 -->
|
||||
<a-menu-item
|
||||
key="edit"
|
||||
@click.prevent.stop="handleEditClick(item)"
|
||||
v-auth="'llm:airag_mcp:edit'"
|
||||
>
|
||||
<Icon icon="ant-design:edit-outlined" size="16" /> 编辑
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-if="item.synced"
|
||||
key="toggle"
|
||||
@click.prevent.stop="handleToggleStatus(item)"
|
||||
v-auth="'llm:airag_mcp:edit'"
|
||||
>
|
||||
<Icon :icon="item.status === 'enable' ? 'ant-design:stop-outlined' : 'ant-design:check-circle-outlined'" size="16" />
|
||||
{{ item.status === 'enable' ? '禁用' : '启用' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-if="item.status === 'disable' || !item.synced"
|
||||
key="delete"
|
||||
@click.prevent.stop="handleDeleteClick(item)"
|
||||
v-auth="'llm:airag_mcp:delete'"
|
||||
>
|
||||
<Icon icon="ant-design:delete-outlined" size="16" /> 删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
<div class="card-footer" v-if="true">
|
||||
<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 status-pill" :class="item.synced ? (item.status==='enable'?'status-enable-pill':'status-disable-pill'):'status-unsynced-pill'" :title="item.synced ? (item.status==='enable'?'已启用':'未启用'):'未同步'">
|
||||
<Icon :icon="getStatusIcon(item)" class="pill-icon" />
|
||||
<span class="pill-text">{{ item.synced ? (item.status==='enable'?'启用':'禁用') : '未同步' }}</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>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<Pagination
|
||||
v-if="mcpList.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}条` "
|
||||
/>
|
||||
</div>
|
||||
<!-- 弹窗区域 -->
|
||||
<AiragMcpAddModal @register="registerModal" @success="reload" />
|
||||
<AiragMcpDetailModal @register="registerDetailModal" @edit="handleDetailEdit" @success="reload" />
|
||||
</template>
|
||||
<script lang="ts" name="llm-airagMcp" setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { Pagination } from 'ant-design-vue';
|
||||
import AiragMcpAddModal from './components/AiragMcpAddModal.vue';
|
||||
import AiragMcpDetailModal from './components/AiragMcpDetailModal.vue';
|
||||
import { list, deleteOne, syncMcp, toggleStatus} from './AiragMcp.api';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import JInput from '@/components/Form/src/jeecg/components/JInput.vue';
|
||||
import defaultLogo from './imgs/mcpLogo.png'
|
||||
|
||||
// 列表数据
|
||||
const mcpList = ref<any[]>([]);
|
||||
// 分页
|
||||
const pageNo = ref<number>(1);
|
||||
const pageSize = ref<number>(10);
|
||||
const total = ref<number>(0);
|
||||
const pageSizeOptions = ref<any>(['10', '20', '30']);
|
||||
|
||||
// 查询参数
|
||||
const queryParam = reactive<any>({});
|
||||
const formRef = ref();
|
||||
|
||||
// 查询区域label宽度
|
||||
const labelCol = reactive({
|
||||
xs: 24,
|
||||
sm: 4,
|
||||
xl: 6,
|
||||
xxl: 6,
|
||||
});
|
||||
const wrapperCol = reactive({
|
||||
xs: 24,
|
||||
sm: 20,
|
||||
});
|
||||
|
||||
// 弹窗(新增/编辑)
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
// 详情弹窗
|
||||
const [registerDetailModal, { openModal: openDetailModal }] = useModal();
|
||||
|
||||
// 初始化
|
||||
reload();
|
||||
|
||||
function reload() {
|
||||
const params: any = {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
column: 'createTime',
|
||||
order: 'desc',
|
||||
...queryParam,
|
||||
};
|
||||
list(params).then((res) => {
|
||||
if (res.records) {
|
||||
mcpList.value = res.records;
|
||||
total.value = res.total;
|
||||
} else {
|
||||
mcpList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handlePageChange(page, current) {
|
||||
pageNo.value = page;
|
||||
pageSize.value = current;
|
||||
reload();
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
openModal(true, {});
|
||||
}
|
||||
|
||||
function handleEditClick(item) {
|
||||
// 参考模型列表,仅传 id;如需后端查询可在弹窗内部扩展
|
||||
openModal(true, { id: item.id, ...item });
|
||||
}
|
||||
|
||||
function handleDetailClick(item){
|
||||
// 仅传 id,详情弹窗内部调用 queryById 获取最新数据
|
||||
openDetailModal(true, { id: item.id });
|
||||
}
|
||||
|
||||
function handleDetailEdit(record){
|
||||
// 从详情弹窗内部触发编辑
|
||||
openModal(true, { id: record.id, ...record });
|
||||
}
|
||||
|
||||
async function handleDeleteClick(item) {
|
||||
if (mcpList.value.length === 1 && pageNo.value > 1) {
|
||||
pageNo.value = pageNo.value - 1;
|
||||
}
|
||||
await deleteOne({ id: item.id }, reload);
|
||||
}
|
||||
|
||||
async function handleSync(item) {
|
||||
await syncMcp(item.id).finally(() => reload());
|
||||
}
|
||||
|
||||
async function handleToggleStatus(item) {
|
||||
const newStatus = item.status === 'enable' ? 'disable' : 'enable';
|
||||
await toggleStatus(item.id , newStatus).finally(() => reload());
|
||||
}
|
||||
|
||||
function searchQuery() {
|
||||
pageNo.value = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
function searchReset() {
|
||||
formRef.value?.resetFields();
|
||||
Object.keys(queryParam).forEach((k) => (queryParam[k] = ''));
|
||||
searchQuery();
|
||||
}
|
||||
|
||||
// 图标处理(如果 icon 是完整URL则使用,否则可以扩展映射)
|
||||
function getIcon(icon?: string) {
|
||||
if (!icon) return defaultLogo;
|
||||
return icon.startsWith('http') ? icon : icon; // 可扩展为本地静态资源路径
|
||||
}
|
||||
|
||||
// 工具数量:从 metadata 中读取 tool_count,可处理对象或 JSON 字符串
|
||||
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';
|
||||
}
|
||||
|
||||
// 工具管理 - 打开详情页面
|
||||
function handleToolManage(item: any) {
|
||||
openDetailModal(true, { id: item.id });
|
||||
}
|
||||
|
||||
// 状态/同步图标
|
||||
function getStatusIcon(item: any) {
|
||||
if (!item.synced) return 'ant-design:cloud-sync-outlined';
|
||||
return item.status === 'enable' ? 'ant-design:check-circle-outlined' : 'ant-design:stop-outlined';
|
||||
}
|
||||
|
||||
// <script setup> 下自动暴露到模板, 无需 export
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.mcp {
|
||||
height: calc(100vh - 115px);
|
||||
background: #f7f8fc;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
.mcp-row {
|
||||
/* 允许阴影完整显示 */
|
||||
margin-top: 20px;
|
||||
padding-bottom: 12px;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 20px;
|
||||
:deep(.ant-col) { flex: 0 0 270px; max-width: 270px; }
|
||||
.mcp-card, .add-mcp-card { width: 270px; }
|
||||
.mcp-header {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
.header-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.header-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #354052;
|
||||
width: calc(100% - 80px);
|
||||
overflow: hidden;
|
||||
align-content: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
align-self: center;
|
||||
color: #8a8f98;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.no-activate {
|
||||
font-size: 10px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
height: 14px;
|
||||
line-height: 12px;
|
||||
margin-left: 6px;
|
||||
align-self: center;
|
||||
}
|
||||
.described {
|
||||
font-weight: 400;
|
||||
margin-left: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
/* Fallback for supporting browsers */
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
max-width: 100%;
|
||||
line-height: 18px;
|
||||
color: #848b99;
|
||||
}
|
||||
.status-enable {
|
||||
color: #52c41a;
|
||||
}
|
||||
.status-disable {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
.status-unsynced {
|
||||
color: #fa8c16;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
:deep(.ant-card .ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
.mr-14 {
|
||||
margin-right: 14px;
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ml-4 {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.mcp-btn {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 6px;
|
||||
height: auto;
|
||||
display: none;
|
||||
}
|
||||
.mcp-card {
|
||||
background: #fcfcfd;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 4px #e6e6e6;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 10px;
|
||||
height: 152px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.mcp-card:hover {
|
||||
box-shadow: 0 6px 12px #d0d3d8;
|
||||
.mcp-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.tool-count {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(245,246,247,0.9);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.card-footer {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px 2px 8px;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
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: 4px; }
|
||||
}
|
||||
.type-pill { background: linear-gradient(135deg,#e6f4ff,#f0f9ff); color:#0958d9; }
|
||||
.status-enable-pill { background: linear-gradient(135deg,#e8f9e9,#f0fff0); color:#2f952f; }
|
||||
.status-disable-pill { background: linear-gradient(135deg,#fff1f0,#ffecec); color:#c43826; }
|
||||
.status-unsynced-pill { background: linear-gradient(135deg,#fff7e6,#fff3d9); color:#d46b08; }
|
||||
.tool-pill { background: linear-gradient(135deg,#f5f6f7,#f0f1f2); color:#555; }
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-footer {
|
||||
text-align: right;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
.add-mcp-card {
|
||||
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: 270px;
|
||||
.add-mcp-card-icon {
|
||||
padding: 8px;
|
||||
color: #1f2329;
|
||||
background-color: #f5f6f7;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.add-mcp-card-title {
|
||||
font-size: 16px;
|
||||
color: #1f2329;
|
||||
font-weight: 400;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
.add-mcp-card:hover {
|
||||
box-shadow: 0 6px 12px #d0d3d8;
|
||||
}
|
||||
.mcp-icon {
|
||||
background-color: unset;
|
||||
border: none;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.mcp-icon:hover {
|
||||
color: #000000;
|
||||
background-color: #e9ecf2;
|
||||
border: none;
|
||||
}
|
||||
.ant-dropdown-link {
|
||||
font-size: 14px;
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,562 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
destroyOnClose
|
||||
@register="registerModal"
|
||||
:canFullscreen="false"
|
||||
width="640px"
|
||||
:title="isEdit ? '编辑MCP' : '新增MCP'"
|
||||
wrapClassName="airag-mcp-modal"
|
||||
>
|
||||
<div class="modal">
|
||||
<div class="mcp-content">
|
||||
<BasicForm @register="registerForm">
|
||||
<!-- category类型选择:单选 -->
|
||||
<template #category="{ model, field }">
|
||||
<a-radio-group v-model:value="model[field]" @change="onCategoryChange" :disabled="isEdit">
|
||||
<a-radio value="mcp">MCP</a-radio>
|
||||
<a-radio value="plugin">插件</a-radio>
|
||||
</a-radio-group>
|
||||
</template>
|
||||
<!-- MCP类型选择 -->
|
||||
<template #type="{ model, field }">
|
||||
<a-select v-model:value="model[field]" @change="onTypeChange" :disabled="isEdit">
|
||||
<a-select-option value="sse">SSE</a-select-option>
|
||||
<a-select-option value="stdio">STDIO</a-select-option>
|
||||
<a-select-option value="http">HTTP</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<!-- endpoint 根据类型切换 placeholder -->
|
||||
<template #endpoint="{ model, field }">
|
||||
<a-input
|
||||
v-model:value="model[field]"
|
||||
:placeholder="endpointPlaceholder"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</template>
|
||||
<!-- headers 根据类型切换标签名称与 placeholder -->
|
||||
<template #headers>
|
||||
<!-- 解决浏览抛warning 70+ -->
|
||||
<a-form-item-rest>
|
||||
<div class="headers-table-wrapper">
|
||||
<a-button type="primary" size="small" @click="addHeaderRow" style="margin-bottom: 8px;">
|
||||
添加
|
||||
</a-button>
|
||||
<div class="headers-table-container">
|
||||
<a-table
|
||||
:dataSource="headersData"
|
||||
:columns="headersColumns"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'key'">
|
||||
<a-input v-model:value="record.key" placeholder="请输入键" size="small" />
|
||||
</template>
|
||||
<template v-if="column.key === 'value'">
|
||||
<a-input v-model:value="record.value" placeholder="请输入值" size="small" />
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" danger size="small" @click="deleteHeaderRow(index)">删除</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item-rest>
|
||||
</template>
|
||||
<!-- 授权方式配置(仅插件类型) -->
|
||||
<template #authType="{ model, field }">
|
||||
<a-select v-model:value="model[field]" @change="onAuthTypeChange">
|
||||
<a-select-option value="none">不需要授权</a-select-option>
|
||||
<a-select-option value="token">Token / API Key</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<!-- Token参数名(仅插件类型且选择Token授权时) -->
|
||||
<template #tokenParamName="{ model, field }">
|
||||
<a-input v-model:value="model[field]" placeholder="请输入Token参数名" />
|
||||
</template>
|
||||
<!-- Token参数值(仅插件类型且选择Token授权时) -->
|
||||
<template #tokenParamValue="{ model, field }">
|
||||
<a-input autocomplete="new-password" v-model:value="model[field]" type="password" placeholder="请输入Token值" />
|
||||
</template>
|
||||
</BasicForm>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<a-button @click="close">关闭</a-button>
|
||||
<a-button type="primary" @click="handleSubmit" :loading="submitLoading">保存</a-button>
|
||||
<a-button v-if="categoryValue === 'mcp'" type="primary" @click="handleSubmitAndSync" :loading="submitLoading">保存并同步</a-button>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { useModalInner } from '/@/components/Modal';
|
||||
import BasicModal from '/@/components/Modal/src/BasicModal.vue';
|
||||
import BasicForm from '/@/components/Form/src/BasicForm.vue';
|
||||
import { useForm } from '/@/components/Form';
|
||||
import { saveOrUpdate, saveAndSync } from '../AiragMcp.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
|
||||
const emit = defineEmits(['success', 'register']);
|
||||
const { createMessage } = useMessage();
|
||||
const isEdit = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const recordId = ref<string | undefined>(undefined);
|
||||
|
||||
// category类型(plugin/mcp)
|
||||
const categoryValue = ref('mcp');
|
||||
// MCP类型(sse/stdio)
|
||||
const typeValue = ref('sse');
|
||||
// 授权方式
|
||||
const authType = ref('none'); // none: 不需要授权, token: Token / API Key
|
||||
// Token配置
|
||||
const tokenParamName = ref('X-Access-Token');
|
||||
const tokenParamValue = ref('');
|
||||
|
||||
const endpointPlaceholder = computed(() => {
|
||||
if (categoryValue.value === 'plugin') {
|
||||
return '请输入BaseURL,例如:https://api.example.com(可选,不填使用当前系统地址)';
|
||||
}
|
||||
return typeValue.value === 'sse' || typeValue.value === 'http' ? '请输入服务端点URL,例如:https://example.com/stream' : '请输入启动命令,例如:./start-mcp-service';
|
||||
});
|
||||
|
||||
// 表格数据
|
||||
const headersData = ref<Array<{ key: string; value: string }>>([]);
|
||||
|
||||
// 表格列配置
|
||||
const headersColumns = [
|
||||
{ title: '键', dataIndex: 'key', key: 'key', width: '40%' },
|
||||
{ title: '值', dataIndex: 'value', key: 'value', width: '45%' },
|
||||
{ title: '操作', key: 'action', width: '15%' },
|
||||
];
|
||||
|
||||
// 添加行
|
||||
function addHeaderRow() {
|
||||
headersData.value.push({ key: '', value: '' });
|
||||
}
|
||||
|
||||
// 删除行
|
||||
function deleteHeaderRow(index: number) {
|
||||
// 如果只剩一行
|
||||
if (headersData.value.length <= 1) {
|
||||
const lastRow = headersData.value[0];
|
||||
// 判断最后一行是否为空
|
||||
if (!lastRow.key && !lastRow.value) {
|
||||
// 如果键和值都是空的,不处理
|
||||
return;
|
||||
} else {
|
||||
// 如果不是空的,清空这一行的键和值
|
||||
lastRow.key = '';
|
||||
lastRow.value = '';
|
||||
createMessage.success('已清空数据');
|
||||
return;
|
||||
}
|
||||
}
|
||||
headersData.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// 将表格数据转换为JSON字符串格式
|
||||
function headersDataToString(): string {
|
||||
const filtered = headersData.value.filter(item => item.key && item.value);
|
||||
if (filtered.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const obj = filtered.reduce((acc, item) => {
|
||||
acc[item.key] = item.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
// 将JSON字符串格式转换为表格数据
|
||||
function stringToHeadersData(str: string | undefined) {
|
||||
if (!str) {
|
||||
// 默认添加一行空数据
|
||||
headersData.value = [{ key: '', value: '' }];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(str);
|
||||
const entries = Object.entries(obj);
|
||||
if (entries.length === 0) {
|
||||
// 如果解析后没有数据,默认添加一行空数据
|
||||
headersData.value = [{ key: '', value: '' }];
|
||||
} else {
|
||||
headersData.value = entries.map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value)
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON解析失败,默认添加一行空数据
|
||||
console.error('解析headers JSON失败:', e);
|
||||
headersData.value = [{ key: '', value: '' }];
|
||||
}
|
||||
}
|
||||
|
||||
function onAuthTypeChange(val: string) {
|
||||
authType.value = val;
|
||||
updateSchema([
|
||||
{ field: 'tokenParamName', required: val === 'token', show: val === 'token' },
|
||||
{ field: 'tokenParamValue', required: false, show: val === 'token' },
|
||||
]);
|
||||
}
|
||||
|
||||
function onCategoryChange(val) {
|
||||
categoryValue.value = val.target.value;
|
||||
if (categoryValue.value === 'plugin') {
|
||||
// 插件类型:显示BaseURL、请求头、授权方式,隐藏MCP类型选择
|
||||
updateSchema([
|
||||
{ field: 'category', label: '类型' },
|
||||
{ field: 'endpoint', label: 'BaseURL', required: false },
|
||||
{ field: 'type', show: false },
|
||||
{ field: 'headers', label: '请求头', show: true },
|
||||
{ field: 'authType', label: '授权方式', required: true, show: true },
|
||||
{ field: 'tokenParamName', label: 'Token参数名', required: authType.value === 'token', show: authType.value === 'token' },
|
||||
{ field: 'tokenParamValue', label: 'Token参数值', required: false , show: authType.value === 'token' },
|
||||
]);
|
||||
// 设置插件类型的默认值
|
||||
setFieldsValue({
|
||||
type: 'api',
|
||||
authType: authType.value || 'none',
|
||||
tokenParamName: tokenParamName.value || 'X-Access-Token',
|
||||
});
|
||||
} else {
|
||||
// MCP类型:显示原有字段
|
||||
updateSchema([
|
||||
{ field: 'category', label: '类型' },
|
||||
{ field: 'endpoint', label: typeValue.value === 'stdio' ? '命令' : 'URL', required: true },
|
||||
{ field: 'type', label: 'MCP类型', show: true },
|
||||
{ field: 'headers', label: '请求头', show:() =>typeValue.value !=='stdio'},
|
||||
{ field: 'authType', show: false },
|
||||
{ field: 'tokenParamName', show: false },
|
||||
{ field: 'tokenParamValue', show: false },
|
||||
]);
|
||||
// 设置MCP类型的默认值
|
||||
setFieldsValue({
|
||||
type: typeValue.value || 'sse',
|
||||
authType: undefined,
|
||||
tokenParamName: undefined,
|
||||
tokenParamValue: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 表单配置
|
||||
const [registerForm, { resetFields, validate, setFieldsValue, updateSchema }] = useForm({
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
baseColProps: { span: 24 },
|
||||
schemas: [
|
||||
{ field: 'name', component: 'Input', label: '名称', required: true, componentProps: { placeholder: '请输入名称' } },
|
||||
{ field: 'icon', label: '图标', component: 'JImageUpload' },
|
||||
{ field: 'category', component: 'RadioGroup', label: '类型', required: true, slot: 'category', defaultValue: 'mcp' },
|
||||
{ field: 'type', component: 'Select', label: 'MCP类型', required: true, slot: 'type', defaultValue: 'sse' },
|
||||
{ field: 'endpoint', component: 'Input', label: 'URL', required: true, slot: 'endpoint' },
|
||||
{ field: 'headers', component: 'InputTextArea', label: '请求头', slot: 'headers', show: false },
|
||||
{ field: 'authType', component: 'Select', label: '授权方式', required: true, slot: 'authType', defaultValue: 'none', show: false },
|
||||
{ field: 'tokenParamName', component: 'Input', label: 'Token参数名', required: false, slot: 'tokenParamName', defaultValue: 'X-Access-Token', show: false },
|
||||
{ field: 'tokenParamValue', component: 'Input', label: 'Token参数值', required: false, slot: 'tokenParamValue', show: false },
|
||||
{ field: 'descr', component: 'InputTextArea', label: '描述', componentProps: { rows: 3, placeholder: '请输入描述' } },
|
||||
]
|
||||
});
|
||||
|
||||
const [registerModal, { closeModal }] = useModalInner(async (data) => {
|
||||
await resetFields();
|
||||
submitLoading.value = false;
|
||||
recordId.value = data?.id;
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
// 区分新增/编辑
|
||||
if (data.id) {
|
||||
isEdit.value = true;
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
}
|
||||
// 获取category,默认为mcp
|
||||
const category = data.category || 'mcp';
|
||||
categoryValue.value = category;
|
||||
const t = data.type || 'sse';
|
||||
typeValue.value = t;
|
||||
|
||||
// 解析授权配置(从headers或metadata中解析)
|
||||
let parsedAuthType = 'none';
|
||||
let parsedTokenParamName = 'X-Access-Token';
|
||||
let parsedTokenParamValue = '';
|
||||
|
||||
if (category === 'plugin') {
|
||||
// 尝试从metadata中解析授权配置
|
||||
if (data.metadata) {
|
||||
try {
|
||||
const metadata = typeof data.metadata === 'string' ? JSON.parse(data.metadata) : data.metadata;
|
||||
parsedAuthType = metadata.authType || 'none';
|
||||
parsedTokenParamName = metadata.tokenParamName || 'X-Access-Token';
|
||||
parsedTokenParamValue = metadata.tokenParamValue || '';
|
||||
} catch (e) {
|
||||
// 解析失败,使用默认值
|
||||
}
|
||||
}
|
||||
|
||||
// 从headers中提取token(如果存在)
|
||||
let headersObj: any = {};
|
||||
if (data.headers) {
|
||||
try {
|
||||
headersObj = typeof data.headers === 'string' ? JSON.parse(data.headers) : data.headers;
|
||||
// 如果metadata中有token配置,尝试从headers中提取对应的值
|
||||
if (parsedAuthType === 'token' && parsedTokenParamName && headersObj[parsedTokenParamName]) {
|
||||
parsedTokenParamValue = headersObj[parsedTokenParamName];
|
||||
// 从headers中移除token,避免在表格中显示
|
||||
delete headersObj[parsedTokenParamName];
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败
|
||||
}
|
||||
}
|
||||
|
||||
authType.value = parsedAuthType;
|
||||
tokenParamName.value = parsedTokenParamName;
|
||||
tokenParamValue.value = parsedTokenParamValue;
|
||||
|
||||
updateSchema([
|
||||
{ field: 'category', label: '类型', componentProps: { disabled: true } },
|
||||
{ field: 'endpoint', label: 'BaseURL', required: false },
|
||||
{ field: 'type', show: false },
|
||||
{ field: 'headers', label: '请求头', show: true },
|
||||
{ field: 'authType', label: '授权方式', required: true, show: true },
|
||||
{ field: 'tokenParamName', label: 'Token参数名', required: parsedAuthType === 'token', show: parsedAuthType === 'token' },
|
||||
{ field: 'tokenParamValue', label: 'Token参数值', required: false, show: parsedAuthType === 'token' },
|
||||
]);
|
||||
// 将处理后的headers(已移除token)转换为表格数据
|
||||
stringToHeadersData(Object.keys(headersObj).length > 0 ? JSON.stringify(headersObj) : '');
|
||||
|
||||
// 需要在下一个tick设置值,确保updateSchema完成
|
||||
nextTick(() => {
|
||||
setFieldsValue({
|
||||
icon: data.icon,
|
||||
name: data.name,
|
||||
descr: data.descr,
|
||||
category: category,
|
||||
type: t,
|
||||
endpoint: data.endpoint,
|
||||
authType: parsedAuthType,
|
||||
tokenParamName: parsedTokenParamName,
|
||||
tokenParamValue: parsedTokenParamValue,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
updateSchema([
|
||||
{ field: 'category', label: '类型', componentProps: { disabled: true } },
|
||||
{ field: 'endpoint', label: t === 'stdio' ? '命令' : 'URL', required: true },
|
||||
{ field: 'type', label: 'MCP类型', show: true },
|
||||
{ field: 'headers', label: '请求头', show:() => t !== 'stdio' },
|
||||
{ field: 'authType', show: false },
|
||||
{ field: 'tokenParamName', show: false },
|
||||
{ field: 'tokenParamValue', show: false },
|
||||
]);
|
||||
// 将 headers 字符串转换为表格数据
|
||||
stringToHeadersData(data.headers);
|
||||
|
||||
// 需要在下一个tick设置值,确保updateSchema完成
|
||||
nextTick(() => {
|
||||
setFieldsValue({
|
||||
icon: data.icon,
|
||||
name: data.name,
|
||||
descr: data.descr,
|
||||
category: category,
|
||||
type: t,
|
||||
endpoint: data.endpoint,
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
categoryValue.value = 'mcp';
|
||||
typeValue.value = 'sse';
|
||||
authType.value = 'none';
|
||||
tokenParamName.value = 'X-Access-Token';
|
||||
tokenParamValue.value = '';
|
||||
// 默认添加一行空数据
|
||||
headersData.value = [{ key: '', value: '' }];
|
||||
updateSchema([
|
||||
{ field: 'category', label: '类型' },
|
||||
{ field: 'endpoint', label: 'URL' },
|
||||
{ field: 'headers', label: '请求头', show: true },
|
||||
{ field: 'authType', show: false },
|
||||
{ field: 'tokenParamName', show: false },
|
||||
{ field: 'tokenParamValue', show: false },
|
||||
]);
|
||||
// 设置默认选中值
|
||||
setFieldsValue({ category: 'mcp', type: 'sse', authType: 'none', tokenParamName: 'X-Access-Token', tokenParamValue: '' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 类型改变事件
|
||||
* @param val
|
||||
*/
|
||||
function onTypeChange(val: string){
|
||||
typeValue.value = val;
|
||||
updateSchema([
|
||||
{ field: 'endpoint', label: val === 'stdio' ? '命令' : 'URL' },
|
||||
{ field: 'headers', label: '请求头', show:() => val !== 'stdio' },
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleSubmit(){
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
const values = await validate();
|
||||
if(recordId.value){
|
||||
values.id = recordId.value;
|
||||
}
|
||||
|
||||
if (values.category === 'plugin') {
|
||||
// 插件类型:验证授权配置
|
||||
if (values.authType === 'token') {
|
||||
if (!values.tokenParamName) {
|
||||
createMessage.error('Token授权方式需要填写Token参数名和参数值');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 插件类型:处理headers和授权配置
|
||||
// 合并headers和token配置
|
||||
let headersObj = {};
|
||||
const headersStr = headersDataToString();
|
||||
if (headersStr) {
|
||||
try {
|
||||
headersObj = JSON.parse(headersStr);
|
||||
} catch (e) {
|
||||
headersObj = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 如果选择了Token授权,添加token到headers
|
||||
if (values.authType === 'token' && values.tokenParamName && values.tokenParamValue) {
|
||||
headersObj[values.tokenParamName] = values.tokenParamValue;
|
||||
}
|
||||
|
||||
values.headers = Object.keys(headersObj).length > 0 ? JSON.stringify(headersObj) : '';
|
||||
|
||||
// 将授权配置保存到metadata
|
||||
const metadata: any = {};
|
||||
if (values.metadata) {
|
||||
try {
|
||||
Object.assign(metadata, typeof values.metadata === 'string' ? JSON.parse(values.metadata) : values.metadata);
|
||||
} catch (e) {
|
||||
// 解析失败,使用空对象
|
||||
}
|
||||
}
|
||||
metadata.authType = values.authType || 'none';
|
||||
if (values.authType === 'token') {
|
||||
metadata.tokenParamName = values.tokenParamName || 'X-Access-Token';
|
||||
metadata.tokenParamValue = values.tokenParamValue || '';
|
||||
}
|
||||
values.metadata = JSON.stringify(metadata);
|
||||
|
||||
values.type = 'api';
|
||||
} else {
|
||||
// MCP类型:将表格数据转换为字符串
|
||||
values.headers = headersDataToString();
|
||||
// 清除授权相关字段
|
||||
delete values.authType;
|
||||
delete values.tokenParamName;
|
||||
delete values.tokenParamValue;
|
||||
}
|
||||
|
||||
const res = await saveOrUpdate(values);
|
||||
if(res.success){
|
||||
createMessage.success(res.message || '保存成功');
|
||||
emit('success');
|
||||
closeModal();
|
||||
}else{
|
||||
createMessage.error(res.message || '保存失败');
|
||||
}
|
||||
}finally{
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitAndSync(){
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
const values = await validate();
|
||||
if(recordId.value){
|
||||
values.id = recordId.value;
|
||||
}
|
||||
// 将表格数据转换为字符串
|
||||
values.headers = headersDataToString();
|
||||
const res = await saveAndSync(values);
|
||||
if(res.success){
|
||||
createMessage.success(res.message || '保存并同步成功');
|
||||
emit('success');
|
||||
closeModal();
|
||||
}else{
|
||||
createMessage.error(res.message || '保存并同步失败');
|
||||
}
|
||||
}finally{
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close(){
|
||||
closeModal();
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="less">
|
||||
.modal {
|
||||
padding: 5px 16px 8px 16px;
|
||||
.header {
|
||||
padding: 0 0 12px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.mcp-content {
|
||||
:deep(.ant-form-item) { margin-bottom: 8px; }
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-input-number),
|
||||
:deep(.ant-select),
|
||||
:deep(.ant-select-selector),
|
||||
:deep(.ant-textarea),
|
||||
:deep(textarea.ant-input) { width: 100% !important; }
|
||||
:deep(.ant-select-selector){ padding: 0 8px; }
|
||||
|
||||
.headers-table-wrapper {
|
||||
.headers-table-container {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 2px;
|
||||
|
||||
:deep(.ant-table) {
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
padding: 8px;
|
||||
}
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style lang="less">
|
||||
.airag-mcp-modal {
|
||||
.jeecg-basic-modal-close > span { margin-left: 0 !important; }
|
||||
.ant-modal {
|
||||
max-height: 85vh;
|
||||
.ant-modal-body {
|
||||
max-height: calc(85vh - 110px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,614 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
destroyOnClose
|
||||
@register="registerModal"
|
||||
:canFullscreen="false"
|
||||
width="720px"
|
||||
wrapClassName="airag-mcp-detail-modal"
|
||||
>
|
||||
<template #title>
|
||||
<div class="detail-modal-title">
|
||||
<span>详情</span>
|
||||
<a-button
|
||||
class="detail-edit-btn"
|
||||
type="text"
|
||||
@click="emitEdit"
|
||||
:title="'编辑'"
|
||||
>
|
||||
<Icon icon="ant-design:edit-outlined" :size="18" />
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="detail-modal" v-loading="loading">
|
||||
<div class="detail-header">
|
||||
<img :src="displayIcon" class="detail-icon" />
|
||||
<div class="detail-titles">
|
||||
<div class="detail-name" :title="record?.name">{{ record?.name || '-' }}</div>
|
||||
<div class="detail-type-status">
|
||||
<a-tag color="blue">{{ record?.category === 'plugin' ? '插件' : (record?.type || '未知类型') }}</a-tag>
|
||||
<a-tag v-if="record?.synced" :color="record?.status === 'enable' ? 'green' : 'red'">
|
||||
{{ record?.status === 'enable' ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="orange">未同步</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-descr" :title="record?.descr">{{ record?.descr || '暂无描述' }}</div>
|
||||
<div class="tools-wrapper">
|
||||
<div class="tools-header">
|
||||
<div class="tools-title">工具列表</div>
|
||||
<a-button v-if="record?.category === 'plugin'" type="primary" size="small" @click="handleAddTool">添加工具</a-button>
|
||||
</div>
|
||||
<div class="tools-grid" v-if="(pluginTools && pluginTools.length) || (tools && tools.length)">
|
||||
<!-- 插件类型工具 -->
|
||||
<template v-if="record?.category === 'plugin'">
|
||||
<div
|
||||
v-for="tool in pluginTools"
|
||||
:key="tool.name"
|
||||
class="tool-item"
|
||||
@click="handleEditTool(tool)"
|
||||
>
|
||||
<div class="tool-header-item" @click.stop>
|
||||
<div class="tool-name" :title="tool.name">{{ tool.name }}</div>
|
||||
<div class="tool-actions">
|
||||
<a-switch
|
||||
v-model:checked="tool.enabled"
|
||||
size="small"
|
||||
@change="handleToolEnabledChange(tool)"
|
||||
@click.stop
|
||||
/>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click.stop="handleEditTool(tool)"
|
||||
:title="'编辑工具'"
|
||||
>
|
||||
<Icon icon="ant-design:edit-outlined" :size="16" />
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
@click.stop="handleDeleteTool(tool)"
|
||||
:title="'删除工具'"
|
||||
>
|
||||
<Icon icon="ant-design:delete-outlined" :size="16" />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-descr" :title="tool.description">{{ tool.description || '无描述' }}</div>
|
||||
<div v-if="tool.method || tool.path" class="tool-meta">
|
||||
<a-tag v-if="tool.method" size="small" :color="getMethodColor(tool.method)">{{ tool.method }}</a-tag>
|
||||
<span v-if="tool.path" class="tool-path">{{ tool.path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- MCP类型工具 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="tool in tools"
|
||||
:key="tool.name"
|
||||
class="tool-item"
|
||||
>
|
||||
<div class="tool-header-item" @click.stop>
|
||||
<div class="tool-name" :title="tool.name">{{ tool.name }}</div>
|
||||
</div>
|
||||
<div class="tool-descr" :title="tool.descr">{{ tool.descr || '无描述' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<a-empty v-else description="暂无工具" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<a-button @click="closeModal()">关闭</a-button>
|
||||
</template>
|
||||
</BasicModal>
|
||||
<!-- 工具编辑弹窗 -->
|
||||
<PluginToolEditModal @register="registerToolEditModal" @success="handleToolEditSuccess" />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useModalInner, useModal } from '/@/components/Modal';
|
||||
import BasicModal from '/@/components/Modal/src/BasicModal.vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import { queryById } from '../AiragMcp.api';
|
||||
import defaultLogo from '../imgs/mcpLogo.png';
|
||||
import PluginToolEditModal from './PluginToolEditModal.vue';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
interface McpToolItem {
|
||||
name: string;
|
||||
descr?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface PluginToolItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
method?: string;
|
||||
parameters?: any[];
|
||||
responses?: any[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface McpRecord {
|
||||
id?: string;
|
||||
name?: string;
|
||||
descr?: string;
|
||||
icon?: string;
|
||||
type?: string;
|
||||
category?: string;
|
||||
status?: string;
|
||||
synced?: boolean;
|
||||
tools?: McpToolItem[] | string;
|
||||
endpoint?: string;
|
||||
headers?: string;
|
||||
metadata?: string | any;
|
||||
}
|
||||
|
||||
const emit = defineEmits(['register','edit', 'success']);
|
||||
const record = ref<McpRecord | null>(null);
|
||||
const tools = ref<McpToolItem[]>([]);
|
||||
const pluginTools = ref<PluginToolItem[]>([]);
|
||||
const loading = ref<boolean>(false);
|
||||
|
||||
// 工具编辑弹窗
|
||||
const [registerToolEditModal, { openModal: openToolEditModal }] = useModal();
|
||||
|
||||
const displayIcon = computed(() => {
|
||||
const icon = record.value?.icon;
|
||||
if (!icon) return defaultLogo;
|
||||
return icon.startsWith('http') ? icon : icon;
|
||||
});
|
||||
|
||||
const [registerModal, { closeModal }] = useModalInner(async (data: McpRecord) => {
|
||||
if(!data?.id){
|
||||
record.value = { ...data };
|
||||
// 根据category初始化工具列表
|
||||
if (data.category === 'plugin') {
|
||||
try {
|
||||
const toolsData = typeof data.tools === 'string' ? JSON.parse(data.tools) : data.tools;
|
||||
pluginTools.value = Array.isArray(toolsData) ? toolsData : [];
|
||||
} catch (e) {
|
||||
pluginTools.value = [];
|
||||
}
|
||||
tools.value = [];
|
||||
} else {
|
||||
tools.value = Array.isArray(data.tools) ? data.tools : [];
|
||||
pluginTools.value = [];
|
||||
}
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await queryById(data.id);
|
||||
// 后端返回结构直接使用 res.result 或 res depending on transform; 假设统一为 res.result
|
||||
const detail = res.result || res; // 兼容不同返回包装
|
||||
record.value = {
|
||||
id: detail.id,
|
||||
name: detail.name,
|
||||
descr: detail.descr,
|
||||
icon: detail.icon,
|
||||
type: detail.type,
|
||||
category: detail.category,
|
||||
status: detail.status,
|
||||
synced: !!detail.synced,
|
||||
endpoint: detail.endpoint,
|
||||
headers: detail.headers,
|
||||
metadata: detail.metadata,
|
||||
};
|
||||
|
||||
// 根据category解析工具
|
||||
if (detail.category === 'plugin') {
|
||||
// 插件类型:解析tools字段为插件工具列表
|
||||
let parsedPluginTools: PluginToolItem[] = [];
|
||||
const rawTools = detail.tools;
|
||||
if (rawTools) {
|
||||
try {
|
||||
if (typeof rawTools === 'string') {
|
||||
parsedPluginTools = JSON.parse(rawTools);
|
||||
} else if (Array.isArray(rawTools)) {
|
||||
parsedPluginTools = rawTools;
|
||||
}
|
||||
} catch (e) {
|
||||
parsedPluginTools = [];
|
||||
}
|
||||
}
|
||||
// 确保每个工具都有enabled字段,默认为true
|
||||
pluginTools.value = parsedPluginTools.map((t: any) => ({
|
||||
...t,
|
||||
enabled: t.enabled !== undefined ? t.enabled : true
|
||||
}));
|
||||
tools.value = [];
|
||||
} else {
|
||||
// MCP类型:解析tools字段为MCP工具列表
|
||||
let parsedTools: McpToolItem[] = [];
|
||||
const rawTools = detail.tools;
|
||||
if (rawTools) {
|
||||
try {
|
||||
if (typeof rawTools === 'string') {
|
||||
const arr = JSON.parse(rawTools);
|
||||
parsedTools = arr.map((t: any) => ({
|
||||
name: t.name,
|
||||
descr: t.description,
|
||||
enabled: t.enabled !== undefined ? t.enabled : true
|
||||
}));
|
||||
} else if (Array.isArray(rawTools)) {
|
||||
parsedTools = rawTools.map((t: any) => ({
|
||||
name: t.name,
|
||||
descr: t.description,
|
||||
enabled: t.enabled !== undefined ? t.enabled : true
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
parsedTools = [];
|
||||
}
|
||||
}
|
||||
tools.value = parsedTools;
|
||||
pluginTools.value = [];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function handleAddTool() {
|
||||
openToolEditModal(true, {
|
||||
pluginId: record.value?.id,
|
||||
plugin: record.value,
|
||||
tool: null, // 新增
|
||||
});
|
||||
}
|
||||
|
||||
function handleEditTool(tool: PluginToolItem) {
|
||||
openToolEditModal(true, {
|
||||
pluginId: record.value?.id,
|
||||
plugin: record.value,
|
||||
tool: tool,
|
||||
});
|
||||
}
|
||||
|
||||
function getMethodColor(method: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
'GET': 'blue',
|
||||
'POST': 'green',
|
||||
'PUT': 'orange',
|
||||
'DELETE': 'red',
|
||||
'PATCH': 'purple',
|
||||
};
|
||||
return colorMap[method] || 'default';
|
||||
}
|
||||
|
||||
async function handleToolEnabledChange(tool: PluginToolItem) {
|
||||
// 更新工具启用状态
|
||||
if (!record.value?.id) return;
|
||||
|
||||
try {
|
||||
const res = await queryById(record.value.id);
|
||||
const detail = res.result || res;
|
||||
let tools: any[] = [];
|
||||
if (detail.tools) {
|
||||
try {
|
||||
tools = typeof detail.tools === 'string' ? JSON.parse(detail.tools) : detail.tools;
|
||||
} catch (e) {
|
||||
tools = [];
|
||||
}
|
||||
}
|
||||
const index = tools.findIndex((t: any) => t.name === tool.name);
|
||||
if (index >= 0) {
|
||||
tools[index].enabled = tool.enabled;
|
||||
const { saveTools } = await import('../AiragMcp.api');
|
||||
await saveTools(record.value.id, JSON.stringify(tools));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('更新工具启用状态失败:', e);
|
||||
// 恢复状态
|
||||
tool.enabled = !tool.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteTool(tool: PluginToolItem) {
|
||||
// 删除工具前进行二次确认
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除工具"${tool.name}"吗?此操作不可恢复。`,
|
||||
okText: '确定',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (!record.value?.id) {
|
||||
createMessage.error('插件ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取最新的工具列表
|
||||
const res = await queryById(record.value.id);
|
||||
const detail = res.result || res;
|
||||
let tools: any[] = [];
|
||||
if (detail.tools) {
|
||||
try {
|
||||
tools = typeof detail.tools === 'string' ? JSON.parse(detail.tools) : detail.tools;
|
||||
} catch (e) {
|
||||
tools = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 从工具列表中移除该工具
|
||||
const filteredTools = tools.filter((t: any) => t.name !== tool.name);
|
||||
|
||||
if (filteredTools.length === tools.length) {
|
||||
createMessage.warning('未找到要删除的工具');
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存更新后的工具列表
|
||||
const { saveTools } = await import('../AiragMcp.api');
|
||||
const saveRes = await saveTools(record.value.id, JSON.stringify(filteredTools));
|
||||
|
||||
if (saveRes.success) {
|
||||
createMessage.success('删除成功');
|
||||
// 更新前端显示
|
||||
pluginTools.value = pluginTools.value.filter((t: any) => t.name !== tool.name);
|
||||
// 触发成功事件,通知父组件刷新
|
||||
emit('success');
|
||||
} else {
|
||||
createMessage.error(saveRes.message || '删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('删除工具失败:', e);
|
||||
createMessage.error('删除工具失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function emitEdit() {
|
||||
// 触发编辑事件,传递record给父组件
|
||||
if (record.value) {
|
||||
emit('edit', record.value);
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolEditSuccess() {
|
||||
// 重新加载详情数据
|
||||
if (record.value?.id) {
|
||||
loading.value = true;
|
||||
queryById(record.value.id).then((res: any) => {
|
||||
const detail = res.result || res;
|
||||
|
||||
// 更新record信息
|
||||
record.value = {
|
||||
id: detail.id,
|
||||
name: detail.name,
|
||||
descr: detail.descr,
|
||||
icon: detail.icon,
|
||||
type: detail.type,
|
||||
category: detail.category,
|
||||
status: detail.status,
|
||||
synced: !!detail.synced,
|
||||
endpoint: detail.endpoint,
|
||||
headers: detail.headers,
|
||||
metadata: detail.metadata,
|
||||
};
|
||||
|
||||
// 根据category解析工具
|
||||
if (detail.category === 'plugin') {
|
||||
// 插件类型:解析tools字段为插件工具列表
|
||||
let parsedPluginTools: PluginToolItem[] = [];
|
||||
const rawTools = detail.tools;
|
||||
if (rawTools) {
|
||||
try {
|
||||
if (typeof rawTools === 'string') {
|
||||
parsedPluginTools = JSON.parse(rawTools);
|
||||
} else if (Array.isArray(rawTools)) {
|
||||
parsedPluginTools = rawTools;
|
||||
}
|
||||
} catch (e) {
|
||||
parsedPluginTools = [];
|
||||
}
|
||||
}
|
||||
pluginTools.value = parsedPluginTools.map((t: any) => ({
|
||||
...t,
|
||||
enabled: t.enabled !== undefined ? t.enabled : true
|
||||
}));
|
||||
tools.value = [];
|
||||
} else {
|
||||
// MCP类型:解析tools字段为MCP工具列表
|
||||
let parsedTools: McpToolItem[] = [];
|
||||
const rawTools = detail.tools;
|
||||
if (rawTools) {
|
||||
try {
|
||||
if (typeof rawTools === 'string') {
|
||||
const arr = JSON.parse(rawTools);
|
||||
parsedTools = arr.map((t: any) => ({ name: t.name, descr: t.description }));
|
||||
} else if (Array.isArray(rawTools)) {
|
||||
parsedTools = rawTools.map((t: any) => ({ name: t.name, descr: t.description }));
|
||||
}
|
||||
} catch (e) {
|
||||
parsedTools = [];
|
||||
}
|
||||
}
|
||||
tools.value = parsedTools;
|
||||
pluginTools.value = [];
|
||||
}
|
||||
loading.value = false;
|
||||
// 触发success事件,通知列表页刷新
|
||||
emit('success');
|
||||
}).catch(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="less">
|
||||
.detail-modal {
|
||||
padding: 12px 16px 8px 16px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.detail-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
margin-right: 16px;
|
||||
background: #f5f6f7;
|
||||
object-fit: cover;
|
||||
}
|
||||
.detail-titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.detail-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
line-height: 28px;
|
||||
max-width: 480px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-type-status { margin-top: 4px; }
|
||||
}
|
||||
}
|
||||
.detail-descr {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: #4e5969;
|
||||
background: #f5f6f7;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tools-wrapper {
|
||||
margin-top: 16px;
|
||||
.tools-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tools-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.tool-item {
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow 0.25s;
|
||||
cursor: default;
|
||||
&:hover { box-shadow: 0 4px 10px rgba(0,0,0,0.08); }
|
||||
&.tool-item-plugin {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-header-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
.tool-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-btn-text {
|
||||
padding: 0;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 77, 79, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tool-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.tool-descr {
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
line-height: 18px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tool-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
.tool-path {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding-right: 40px;
|
||||
|
||||
.detail-edit-btn {
|
||||
padding: 4px 8px;
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style lang="less">
|
||||
.airag-mcp-detail-modal {
|
||||
.jeecg-basic-modal-close > span { margin-left: 0 !important; }
|
||||
|
||||
:deep(.ant-modal-header) {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-title) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
destroyOnClose
|
||||
@register="registerModal"
|
||||
:canFullscreen="false"
|
||||
width="1400px"
|
||||
:title="isEdit ? '编辑工具' : '新增工具'"
|
||||
wrapClassName="plugin-tool-edit-modal"
|
||||
>
|
||||
<div class="modal">
|
||||
<div class="tool-edit-content">
|
||||
<!-- 基本信息 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
<a-form :model="toolForm" layout="vertical">
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="工具名称" required>
|
||||
<a-input v-model:value="toolForm.name" placeholder="请输入工具名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="请求方式" required>
|
||||
<a-select v-model:value="toolForm.method" placeholder="请选择请求方式">
|
||||
<a-select-option value="GET">GET</a-select-option>
|
||||
<a-select-option value="POST">POST</a-select-option>
|
||||
<a-select-option value="PUT">PUT</a-select-option>
|
||||
<a-select-option value="DELETE">DELETE</a-select-option>
|
||||
<a-select-option value="PATCH">PATCH</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="访问路径" required>
|
||||
<a-input v-model:value="toolForm.path" placeholder="请输入访问路径,如:/api/user/{userId}" @blur="normalizePath" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="描述" required>
|
||||
<a-textarea v-model:value="toolForm.description" :rows="3" placeholder="请输入工具描述" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 请求参数 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">请求参数</h3>
|
||||
<a-button type="primary" size="small" @click="handleAddRequestParam">添加参数</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:dataSource="requestParams"
|
||||
:columns="requestParamsColumns"
|
||||
:pagination="false"
|
||||
bordered
|
||||
size="small"
|
||||
rowKey="tempId"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<a-input
|
||||
v-model:value="record.name"
|
||||
placeholder="参数名称(字母数字下划线)"
|
||||
@blur="validateParamName(record)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'description'">
|
||||
<a-input v-model:value="record.description" placeholder="参数描述" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-select v-model:value="record.type" style="width: 100%">
|
||||
<a-select-option value="String">String</a-select-option>
|
||||
<a-select-option value="Number">Number</a-select-option>
|
||||
<a-select-option value="Integer">Integer</a-select-option>
|
||||
<a-select-option value="Boolean">Boolean</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'location'">
|
||||
<a-select v-model:value="record.location" style="width: 100%" @change="onLocationChange(record)">
|
||||
<a-select-option value="Body">Raw(json)</a-select-option>
|
||||
<a-select-option value="Form-Data">Form-Data</a-select-option>
|
||||
<a-select-option value="Query">Query</a-select-option>
|
||||
<a-select-option value="Header">Header</a-select-option>
|
||||
<a-select-option value="Path">Path</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'required'">
|
||||
<a-checkbox v-model:checked="record.required" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'defaultValue'">
|
||||
<a-input v-model:value="record.defaultValue" placeholder="默认值" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" danger size="small" @click="handleDeleteRequestParam(index)">删除</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 输出参数 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">输出参数</h3>
|
||||
<a-button type="primary" size="small" @click="handleAddResponseParam">添加参数</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:dataSource="responseParams"
|
||||
:columns="responseParamsColumns"
|
||||
:pagination="false"
|
||||
bordered
|
||||
size="small"
|
||||
rowKey="tempId"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<a-input
|
||||
v-model:value="record.name"
|
||||
placeholder="参数名称(支持点号和数组语法,如data.name、data[].name)"
|
||||
@blur="validateResponseParamName(record)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'description'">
|
||||
<a-input v-model:value="record.description" placeholder="参数描述" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-select v-model:value="record.type" style="width: 100%" @change="onResponseTypeChange(record)">
|
||||
<a-select-option value="String">String</a-select-option>
|
||||
<a-select-option value="Number">Number</a-select-option>
|
||||
<a-select-option value="Integer">Integer</a-select-option>
|
||||
<a-select-option value="Boolean">Boolean</a-select-option>
|
||||
<a-select-option value="Object">Object</a-select-option>
|
||||
<a-select-option value="Array">Array</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" danger size="small" @click="handleDeleteResponseParam(index)">删除</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<a-button @click="close">关闭</a-button>
|
||||
<a-button type="primary" @click="handleSave" :loading="submitLoading">保存</a-button>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useModalInner } from '/@/components/Modal';
|
||||
import BasicModal from '/@/components/Modal/src/BasicModal.vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { queryById, saveTools } from '../AiragMcp.api';
|
||||
|
||||
const emit = defineEmits(['success', 'register']);
|
||||
const { createMessage } = useMessage();
|
||||
const submitLoading = ref(false);
|
||||
const pluginId = ref<string | undefined>(undefined);
|
||||
const isEdit = ref(false);
|
||||
const toolIndex = ref<number>(-1); // 编辑时工具在数组中的索引
|
||||
|
||||
// 工具表单
|
||||
const toolForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
path: '',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// 请求参数列表
|
||||
const requestParams = ref<any[]>([]);
|
||||
// 输出参数列表
|
||||
const responseParams = ref<any[]>([]);
|
||||
|
||||
// 请求参数表格列
|
||||
const requestParamsColumns = [
|
||||
{ title: '参数名称', key: 'name', width: 150 },
|
||||
{ title: '参数描述', key: 'description', width: 200 },
|
||||
{ title: '参数类型', key: 'type', width: 120 },
|
||||
{ title: '传入方式', key: 'location', width: 150 },
|
||||
{ title: '是否必须', key: 'required', width: 100 },
|
||||
{ title: '默认值', key: 'defaultValue', width: 150 },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
];
|
||||
|
||||
// 输出参数表格列
|
||||
const responseParamsColumns = [
|
||||
{ title: '参数名称', key: 'name', width: 200 },
|
||||
{ title: '参数描述', key: 'description', width: 200 },
|
||||
{ title: '参数类型', key: 'type', width: 150 },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
];
|
||||
|
||||
let tempIdCounter = 0;
|
||||
|
||||
const [registerModal, { closeModal }] = useModalInner(async (data) => {
|
||||
pluginId.value = data?.pluginId;
|
||||
isEdit.value = !!data?.tool;
|
||||
|
||||
// 重置表单
|
||||
toolForm.name = '';
|
||||
toolForm.description = '';
|
||||
toolForm.path = '';
|
||||
toolForm.method = 'GET';
|
||||
requestParams.value = [];
|
||||
responseParams.value = [];
|
||||
toolIndex.value = -1;
|
||||
|
||||
if (data?.tool) {
|
||||
// 编辑模式:填充数据
|
||||
toolForm.name = data.tool.name || '';
|
||||
toolForm.description = data.tool.description || '';
|
||||
toolForm.path = data.tool.path || '';
|
||||
toolForm.method = data.tool.method || 'GET';
|
||||
|
||||
requestParams.value = (data.tool.parameters || []).map((p: any) => ({
|
||||
...p,
|
||||
tempId: `req_${tempIdCounter++}`,
|
||||
required: p.required !== undefined ? p.required : false,
|
||||
}));
|
||||
|
||||
responseParams.value = (data.tool.responses || []).map((r: any) => ({
|
||||
...r,
|
||||
tempId: `resp_${tempIdCounter++}`,
|
||||
}));
|
||||
|
||||
// 查找工具在列表中的索引
|
||||
if (pluginId.value) {
|
||||
const res = await queryById(pluginId.value);
|
||||
const detail = res.result || res;
|
||||
if (detail.tools) {
|
||||
try {
|
||||
const tools = typeof detail.tools === 'string' ? JSON.parse(detail.tools) : detail.tools;
|
||||
toolIndex.value = tools.findIndex((t: any) => t.name === data.tool.name);
|
||||
} catch (e) {
|
||||
toolIndex.value = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function validateParamName(record: any) {
|
||||
// 验证参数名称:只允许字母数字下划线
|
||||
if (record.name && !/^[a-zA-Z0-9_]+$/.test(record.name)) {
|
||||
createMessage.warning('参数名称只能包含字母、数字和下划线');
|
||||
record.name = record.name.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
function validateResponseParamName(record: any) {
|
||||
// 验证输出参数名称:支持字母、数字、下划线、点、中括号
|
||||
// 只有Object和Array类型可以使用点号和中括号
|
||||
if (!record.name) return;
|
||||
|
||||
const type = record.type;
|
||||
const name = record.name;
|
||||
|
||||
if (type === 'Object' || type === 'Array') {
|
||||
// Object和Array类型:支持字母、数字、下划线、点、中括号
|
||||
// 例如:data.name, data[String], data[].name
|
||||
if (!/^[a-zA-Z0-9_.\[\]]+$/.test(name)) {
|
||||
createMessage.warning('参数名称只能包含字母、数字、下划线、点和中括号');
|
||||
record.name = name.replace(/[^a-zA-Z0-9_.\[\]]/g, '');
|
||||
}
|
||||
} else {
|
||||
// 其他类型:只允许字母数字下划线
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
|
||||
createMessage.warning('非Object/Array类型参数名称只能包含字母、数字和下划线');
|
||||
record.name = name.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onResponseTypeChange(record: any) {
|
||||
// 当类型改变时,如果类型不是Object或Array,清除点号和中括号
|
||||
if (record.type !== 'Object' && record.type !== 'Array') {
|
||||
if (record.name && /[.\[\]]/.test(record.name)) {
|
||||
createMessage.warning('非Object/Array类型不支持使用点号和中括号');
|
||||
record.name = record.name.replace(/[.\[\]]/g, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onLocationChange(record: any) {
|
||||
// Body和Form-Data可以同时存在,不再警告
|
||||
// 同时存在时,后端默认使用Body
|
||||
}
|
||||
|
||||
function handleAddRequestParam() {
|
||||
requestParams.value.push({
|
||||
tempId: `req_${tempIdCounter++}`,
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'String',
|
||||
location: 'Body',
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteRequestParam(index: number) {
|
||||
requestParams.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function handleAddResponseParam() {
|
||||
responseParams.value.push({
|
||||
tempId: `resp_${tempIdCounter++}`,
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'String',
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteResponseParam(index: number) {
|
||||
responseParams.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
|
||||
// 验证基本字段
|
||||
if (!toolForm.name || !toolForm.description || !toolForm.path || !toolForm.method) {
|
||||
createMessage.warning('请填写完整的工具基本信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验 name 正则 ^[a-zA-Z0-9_-]+$
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(toolForm.name)) {
|
||||
createMessage.warning('工具名称只能包含字母、数字、下划线和中划线');
|
||||
return;
|
||||
}
|
||||
|
||||
// Body和Form-Data可以同时存在,后端会默认使用Body
|
||||
|
||||
if (!pluginId.value) {
|
||||
createMessage.error('插件ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载插件数据
|
||||
const res = await queryById(pluginId.value);
|
||||
const detail = res.result || res;
|
||||
|
||||
// 解析现有工具列表
|
||||
let tools: any[] = [];
|
||||
if (detail.tools) {
|
||||
try {
|
||||
const parsedTools = typeof detail.tools === 'string' ? JSON.parse(detail.tools) : detail.tools;
|
||||
// 确保是数组
|
||||
if (Array.isArray(parsedTools)) {
|
||||
tools = [...parsedTools]; // 复制数组,避免引用问题
|
||||
} else {
|
||||
tools = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析工具列表失败:', e);
|
||||
tools = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 确保tools是数组
|
||||
if (!Array.isArray(tools)) {
|
||||
tools = [];
|
||||
}
|
||||
|
||||
// 构建当前工具数据(移除tempId)
|
||||
const parameters = requestParams.value.map(p => {
|
||||
const { tempId: _tempId, ...param } = p;
|
||||
return param;
|
||||
});
|
||||
const responses = responseParams.value.map(r => {
|
||||
const { tempId: _tempId, ...resp } = r;
|
||||
return resp;
|
||||
});
|
||||
|
||||
const toolData = {
|
||||
name: toolForm.name,
|
||||
description: toolForm.description,
|
||||
path: toolForm.path,
|
||||
method: toolForm.method,
|
||||
enabled: true, // 默认启用
|
||||
parameters,
|
||||
responses,
|
||||
};
|
||||
|
||||
// 根据编辑状态处理工具
|
||||
if (isEdit.value && toolIndex.value >= 0 && toolIndex.value < tools.length) {
|
||||
// 编辑模式:更新现有工具
|
||||
tools[toolIndex.value] = toolData;
|
||||
} else {
|
||||
// 新增模式:添加新工具
|
||||
// 检查工具名称是否已存在
|
||||
const nameExists = tools.some((t: any) => t.name === toolForm.name);
|
||||
if (nameExists) {
|
||||
createMessage.error('工具名称已存在,请使用不同的名称');
|
||||
return;
|
||||
}
|
||||
tools.push(toolData);
|
||||
}
|
||||
|
||||
// 构建工具列表JSON字符串
|
||||
const toolsJson = JSON.stringify(tools);
|
||||
|
||||
// 调用保存工具接口
|
||||
const saveRes = await saveTools(pluginId.value, toolsJson);
|
||||
if (saveRes.success) {
|
||||
createMessage.success('保存成功');
|
||||
emit('success');
|
||||
closeModal();
|
||||
} else {
|
||||
createMessage.error(saveRes.message || '保存失败');
|
||||
}
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
// 规范化路径:确保以/开头
|
||||
function normalizePath() {
|
||||
if (toolForm.path && !toolForm.path.startsWith('/')) {
|
||||
toolForm.path = '/' + toolForm.path;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.modal {
|
||||
padding: 16px;
|
||||
.tool-edit-content {
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
:deep(.ant-table) {
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
padding: 8px;
|
||||
}
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.plugin-tool-edit-modal {
|
||||
.jeecg-basic-modal-close > span { margin-left: 0 !important; }
|
||||
.ant-modal {
|
||||
max-height: 90vh;
|
||||
.ant-modal-body {
|
||||
max-height: calc(90vh - 110px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
BIN
jeecgboot-vue3/src/views/super/airag/aimcp/imgs/mcpLogo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
453
jeecgboot-vue3/src/views/super/airag/aimodel/AiModelList.vue
Normal file
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div class="model">
|
||||
<!--查询区域-->
|
||||
<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 !important;">
|
||||
<a-row :gutter="24">
|
||||
<a-col :lg="6">
|
||||
<a-form-item name="name" label="模板名称">
|
||||
<JInput v-model:value="queryParam.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :lg="6">
|
||||
<a-form-item name="modelType" label="模板类型">
|
||||
<JDictSelectTag v-model:value="queryParam.modelType" dict-code="model_type" />
|
||||
</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="model-row">
|
||||
<a-col :xxl="4" :xl="6" :lg="6" :md="6" :sm="12" :xs="24">
|
||||
<a-card class="add-knowledge-card" @click="handleAdd">
|
||||
<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 modalList" v-if="modalList && modalList.length>0">
|
||||
<a-card class="model-card" @click="handleEditClick(item)">
|
||||
<div class="model-header">
|
||||
<div class="flex">
|
||||
<img :src="getImage(item.provider)" class="header-img" />
|
||||
<div class="header-text">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<ul>
|
||||
<li class="flex mr-14" style="align-items: center;">
|
||||
<span class="label" style="flex-shrink: 0;">模型类型</span>
|
||||
<span class="described" style="flex: 1; min-width: 0;">{{ item.modelType_dictText }}</span>
|
||||
<a-tooltip v-if="!item.activateFlag" title="未激活模型暂无法被系统其他功能调用,激活后可正常使用。">
|
||||
<span class="no-activate">未激活</span>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li class="flex mr-14 mt-6" style="align-items: center;">
|
||||
<span class="label" style="flex-shrink: 0;">基础模型</span>
|
||||
<span class="described" style="flex: 1; min-width: 0;">{{ item.modelName }}</span>
|
||||
</li>
|
||||
<li class="flex mr-14 mt-6" style="align-items: center;">
|
||||
<span class="label" style="flex-shrink: 0;">创建者</span>
|
||||
<span class="described" style="flex: 1; min-width: 0;">{{ item.createBy_dictText || item.createBy }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="model-btn">
|
||||
<a-button class="model-icon" size="small" @click.prevent.stop="handleEditClick(item)">
|
||||
<Icon icon="ant-design:edit-outlined"></Icon>
|
||||
</a-button>
|
||||
<a-dropdown placement="bottomRight" :trigger="['click']" :getPopupContainer="(node) => node.parentNode">
|
||||
<div class="ant-dropdown-link pointer model-icon" @click.prevent.stop>
|
||||
<Icon icon="ant-design:ellipsis-outlined"></Icon>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<!--<a-menu-item key="param" @click="handleParamClick(item.id)">
|
||||
<Icon icon="ant-design:setting-outlined" size="16"></Icon>
|
||||
<span class="ml-4">模型参数配置</span>
|
||||
</a-menu-item>-->
|
||||
<a-menu-item key="delete" @click.prevent.stop="handleDeleteClick(item)">
|
||||
<Icon icon="ant-design:delete-outlined" size="16"></Icon> 删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<Pagination
|
||||
v-if="modalList.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}条` "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!--添加模型弹窗-->
|
||||
<AiModelModal @register="registerModal" @success="reload"></AiModelModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import AiModelModal from './components/AiModelModal.vue';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import { deleteModel, list } from './model.api';
|
||||
import { imageList } from './model.data';
|
||||
import { Pagination } from 'ant-design-vue';
|
||||
import JInput from '@/components/Form/src/jeecg/components/JInput.vue';
|
||||
import JSelectUser from '@/components/Form/src/jeecg/components/JSelectUser.vue';
|
||||
import JDictSelectTag from '@/components/Form/src/jeecg/components/JDictSelectTag.vue';
|
||||
|
||||
export default {
|
||||
name: 'ModelList',
|
||||
components: {
|
||||
JDictSelectTag,
|
||||
JSelectUser,
|
||||
JInput,
|
||||
AiModelModal,
|
||||
Pagination,
|
||||
},
|
||||
setup() {
|
||||
//模型列表
|
||||
const modalList = ref([]);
|
||||
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
|
||||
//当前页数
|
||||
const pageNo = ref<number>(1);
|
||||
//每页条数
|
||||
const pageSize = ref<number>(10);
|
||||
//总条数
|
||||
const total = ref<number>(0);
|
||||
//可选择的页数
|
||||
const pageSizeOptions = ref<any>(['10', '20', '30']);
|
||||
//查询参数
|
||||
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();
|
||||
|
||||
/**
|
||||
* 新增
|
||||
*/
|
||||
async function handleAdd() {
|
||||
openModal(true, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*
|
||||
* @param item
|
||||
*/
|
||||
function handleEditClick(item) {
|
||||
openModal(true, {
|
||||
id: item.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载数据
|
||||
*/
|
||||
function reload() {
|
||||
let params = {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
column: 'createTime',
|
||||
order: 'desc'
|
||||
};
|
||||
Object.assign(params, queryParam);
|
||||
list(params).then((res) => {
|
||||
if (res.success) {
|
||||
modalList.value = res.result.records;
|
||||
total.value = res.result.total;
|
||||
} else {
|
||||
modalList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页改变事件
|
||||
* @param page
|
||||
* @param current
|
||||
*/
|
||||
function handlePageChange(page, current) {
|
||||
pageNo.value = page;
|
||||
pageSize.value = current;
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片
|
||||
* @param name
|
||||
*/
|
||||
const getImage = (name) => {
|
||||
return imageList.value[name];
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除模型
|
||||
* @param item
|
||||
*/
|
||||
async function handleDeleteClick(item) {
|
||||
if(modalList.value.length == 1 && pageNo.value > 1) {
|
||||
pageNo.value = pageNo.value - 1;
|
||||
}
|
||||
await deleteModel({ id: item.id, name: item.name }, reload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
function searchQuery() {
|
||||
pageNo.value = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function searchReset() {
|
||||
formRef.value.resetFields();
|
||||
queryParam.createBy = '';
|
||||
//刷新数据
|
||||
reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数配置点击事件
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
function handleParamClick(id) {}
|
||||
|
||||
return {
|
||||
handleAdd,
|
||||
handleEditClick,
|
||||
registerModal,
|
||||
modalList,
|
||||
reload,
|
||||
pageNo,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
total,
|
||||
handlePageChange,
|
||||
getImage,
|
||||
handleDeleteClick,
|
||||
searchQuery,
|
||||
searchReset,
|
||||
queryParam,
|
||||
labelCol,
|
||||
wrapperCol,
|
||||
formRef,
|
||||
handleParamClick,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.model {
|
||||
height: calc(100vh - 115px);
|
||||
background: #f7f8fc;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
.model-row {
|
||||
max-height: calc(100% - 100px);
|
||||
margin-top: 20px;
|
||||
overflow-y: auto;
|
||||
.model-header {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
.header-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.header-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #354052;
|
||||
width: calc(100% - 80px);
|
||||
overflow: hidden;
|
||||
align-content: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
align-self: center;
|
||||
color: #8a8f98;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.no-activate{
|
||||
font-size: 10px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
height: 14px;
|
||||
line-height: 12px;
|
||||
margin-left: 6px;
|
||||
align-self: center;
|
||||
}
|
||||
.described {
|
||||
font-weight: 400;
|
||||
margin-left: 14px;
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:deep(.ant-card .ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mr-14 {
|
||||
margin-right: 14px;
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.model-btn {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 6px;
|
||||
height: auto;
|
||||
display: none;
|
||||
}
|
||||
.model-card {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 20px;
|
||||
background: #fcfcfd;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 4px #e6e6e6;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 10px;
|
||||
height: 152px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.model-card:hover {
|
||||
box-shadow: 0 6px 12px #d0d3d8;
|
||||
.model-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-footer {
|
||||
text-align: right;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.add-knowledge-card:hover {
|
||||
box-shadow: 0 6px 12px #d0d3d8;
|
||||
}
|
||||
.model-icon{
|
||||
background-color: unset;
|
||||
border: none;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.model-icon:hover{
|
||||
color: #000000;
|
||||
background-color: #e9ecf2;
|
||||
border: none;
|
||||
}
|
||||
.ant-dropdown-link{
|
||||
font-size: 14px;
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,529 @@
|
||||
<template>
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" width="600px" wrapClassName="ai-model-modal">
|
||||
<div class="modal">
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span v-if="dataIndex ==='list' || dataIndex ==='add'" :class="dataIndex === 'list' ? '' : 'add-header-title pointer'" @click="goToList">
|
||||
选择供应商
|
||||
<a-tooltip title="供应商文档" v-if="dataIndex ==='list'">
|
||||
<a style="color: #333333" href="https://help.jeecg.com/aigc/guide/model/#2-%E4%BE%9B%E5%BA%94%E5%95%86%E9%80%89%E6%8B%A9" target="_blank">
|
||||
<Icon style="position:relative;left: -2px;top:1px" icon="ant-design:question-circle-outlined"></Icon>
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
<span v-if="dataIndex === 'add'" class="add-header-title"> > </span>
|
||||
<span v-if="dataIndex === 'add'" style="color: #1f2329">添加 {{ providerName }}</span>
|
||||
</span>
|
||||
|
||||
<a-select v-if="dataIndex === 'list'" :bordered="false" class="header-select" size="small" v-model:value="modelType" @change="handleChange">
|
||||
<a-select-option v-for="item in modelTypeOption" :value="item.value">{{ item.text }}</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="model-content" v-if="dataIndex === 'list'">
|
||||
<a-row :span="24">
|
||||
<a-col :xxl="12" :xl="12" :lg="12" :md="12" :sm="12" :xs="24" v-for="item in modelTypeList">
|
||||
<a-card class="model-card" @click="handleClick(item)">
|
||||
<div class="model-header">
|
||||
<div class="flex">
|
||||
<img :src="getImage(item.value)" class="header-img" />
|
||||
<div class="header-text">{{ item.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<a-tabs v-model:activeKey="activeKey" v-if="dataIndex === 'add' || dataIndex === 'edit'">
|
||||
<a-tab-pane :key="1">
|
||||
<template #tab>
|
||||
<span style="display: flex">
|
||||
基础信息
|
||||
<a-tooltip title="基础信息文档">
|
||||
<a @click.stop style="color: unset" href="https://help.jeecg.com/aigc/guide/model/#31-%E5%A1%AB%E5%86%99%E5%9F%BA%E7%A1%80%E4%BF%A1%E6%81%AF" target="_blank">
|
||||
<Icon style="position:relative;left:2px;top:1px" icon="ant-design:question-circle-outlined"></Icon>
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<div class="model-content">
|
||||
<BasicForm @register="registerForm">
|
||||
<template #modelType="{ model, field }">
|
||||
<a-select v-model:value="model[field]" @change="handleModelTypeChange" :disabled="modelTypeDisabled">
|
||||
<a-select-option v-for="item in modelTypeAddOption" :value="item">
|
||||
<span v-if="item === 'LLM'">语言模型</span>
|
||||
<span v-else-if="item === 'EMBED'">向量模型</span>
|
||||
<span v-else-if="item === 'IMAGE'">图像模型</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
|
||||
<template #modelName="{ model, field }">
|
||||
<AutoComplete v-model:value="model[field]" :options="modelNameAddOption" :filter-option="filterOption">
|
||||
<template #option="{ value, label, descr, type }">
|
||||
<a-tooltip placement="right" color="#ffffff" :overlayInnerStyle="{ color:'#646a73' }">
|
||||
<template #title>
|
||||
<div v-html="getTitle(descr)"></div>
|
||||
</template>
|
||||
<div style="display: flex;justify-content: space-between;">
|
||||
<span>{{label}}</span>
|
||||
<div>
|
||||
<a-tag v-if="type && type.split(',').includes('text')" color="#E8D7C3">文本</a-tag>
|
||||
<a-tag v-if="type && type.split(',').includes('imageGen')" color="#FFEBD3">图像生成</a-tag>
|
||||
<a-tag v-if="type && type.split(',').includes('image')" color="#C3D9DC">图像分析</a-tag>
|
||||
<a-tag v-if="type && type.split(',').includes('vector')" color="#D4E0D8">向量</a-tag>
|
||||
<a-tag v-if="type && type.split(',').includes('embeddings')" color="#FFEBD3">文本嵌入</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</AutoComplete>
|
||||
</template>
|
||||
</BasicForm>
|
||||
<a-alert v-if="!modelActivate" message="模型未激活,请通过下方「保存并激活」按钮激活当前模型" type="warning" show-icon />
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane :key="2" v-if="modelParamsShow">
|
||||
<template #tab>
|
||||
<span style="display: flex">
|
||||
高级配置
|
||||
<a-tooltip title="高级配置文档">
|
||||
<a @click.stop style="color: unset" href="https://help.jeecg.com/aigc/guide/model/#32-%E9%85%8D%E7%BD%AE%E9%AB%98%E7%BA%A7%E5%8F%82%E6%95%B0" target="_blank">
|
||||
<Icon style="position:relative;left:2px;top:1px" icon="ant-design:question-circle-outlined"></Icon>
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<AiModelSeniorForm ref="modelParamsRef" :modelParams="modelParams"></AiModelSeniorForm>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
</div>
|
||||
<template v-if="dataIndex === 'add' || dataIndex === 'edit'" #footer>
|
||||
<a-button @click="cancel">关闭</a-button>
|
||||
<a-button @click="test" v-if="modelActivate" :loading="testLoading" type="default">测试连接</a-button>
|
||||
<a-button @click="save" type="primary" ghost="true">保存</a-button>
|
||||
<a-button @click="test(false)" v-if="!modelActivate" :loading="testLoading" type="primary" >保存并激活</a-button>
|
||||
</template>
|
||||
<template v-else #footer> </template>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModal, useModalInner } from '@/components/Modal';
|
||||
import { initDictOptions } from '@/utils/dict';
|
||||
import model from './model.json';
|
||||
import { AutoComplete } from 'ant-design-vue';
|
||||
|
||||
import BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { useForm } from '@/components/Form';
|
||||
import { formSchema, imageList } from '../model.data';
|
||||
import { editModel, queryById, saveModel, testConn } from '../model.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
const {createMessage: $message, createConfirm} = useMessage();
|
||||
import AiModelSeniorForm from './AiModelSeniorForm.vue';
|
||||
import { cloneDeep } from "lodash-es";
|
||||
export default {
|
||||
name: 'AddModelModal',
|
||||
components: {
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
AiModelSeniorForm,
|
||||
AutoComplete,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
//ai类型数据
|
||||
const modelTypeData = ref<any>([]);
|
||||
//模型类型下拉框
|
||||
const modelTypeOption = ref<any>([]);
|
||||
//模型类型禁用状态
|
||||
const modelTypeDisabled = ref<boolean>(false);
|
||||
//模型类型
|
||||
const modelType = ref<string>('all');
|
||||
//模型供应商
|
||||
const modelTypeList = ref<any>([]);
|
||||
//list:供应商选择页面,add 添加编辑
|
||||
const dataIndex = ref<string>('list');
|
||||
//供应商名称
|
||||
const providerName = ref<string>('');
|
||||
//添加模型类型的option
|
||||
const modelTypeAddOption = ref<any>([]);
|
||||
//添加模型名称的option
|
||||
const modelNameAddOption = ref<any>([]);
|
||||
//模型数据
|
||||
const modelData = ref<any>({});
|
||||
//tab切换对应的key
|
||||
const activeKey = ref<number>(1);
|
||||
//模型参数
|
||||
const modelParams = ref<any>({});
|
||||
//是否显示模型参数
|
||||
const modelParamsShow = ref<boolean>(false);
|
||||
//模型参数ref
|
||||
const modelParamsRef = ref();
|
||||
//测试按钮loading状态
|
||||
const testLoading = ref<boolean>(false);
|
||||
//模型是否已激活
|
||||
const modelActivate = ref<boolean>(false);
|
||||
|
||||
const getImage = (name) => {
|
||||
return imageList.value[name];
|
||||
};
|
||||
//自动填充文本搜索事件
|
||||
const filterOption = (input: string, option: any)=>{
|
||||
return option.value.toUpperCase().indexOf(input.toUpperCase()) >= 0;
|
||||
}
|
||||
|
||||
//表单配置
|
||||
const [registerForm, { resetFields, setFieldsValue, validate, clearValidate }] = useForm({
|
||||
schemas: formSchema,
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
wrapperCol: { span: 24 },
|
||||
});
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
activeKey.value = 1;
|
||||
modelParamsShow.value = false;
|
||||
if(dataIndex.value !== 'list') {
|
||||
//重置表单
|
||||
await resetFields();
|
||||
}
|
||||
setModalProps({ minHeight: 500 });
|
||||
if (data.id) {
|
||||
dataIndex.value = 'edit';
|
||||
let values = await queryById({ id: data.id });
|
||||
if (values) {
|
||||
if(values.result.credential){
|
||||
let credential = JSON.parse(values.result.credential);
|
||||
if(credential.secretKey){
|
||||
values.result.secretKey = credential.secretKey;
|
||||
}
|
||||
if(credential.apiKey){
|
||||
values.result.apiKey = credential.apiKey;
|
||||
}
|
||||
}
|
||||
let provider = values.result.provider;
|
||||
let data = model.data.filter((item) => {
|
||||
return item.value.includes(provider);
|
||||
});
|
||||
if (data && data.length > 0) {
|
||||
modelTypeAddOption.value = data[0].type;
|
||||
modelNameAddOption.value = data[0][values.result.modelType];
|
||||
}
|
||||
if(values.result.modelType && values.result.modelType === 'LLM'){
|
||||
modelParamsShow.value = true;
|
||||
}
|
||||
if (values.result.activateFlag) {
|
||||
modelActivate.value = true;
|
||||
}else{
|
||||
modelActivate.value = false;
|
||||
}
|
||||
if(values.result.modelParams){
|
||||
modelParams.value = JSON.parse(values.result.modelParams)
|
||||
}
|
||||
modelTypeDisabled.value = true;
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...values.result,
|
||||
});
|
||||
//初始化模型提供者
|
||||
initModelProvider();
|
||||
}
|
||||
} else {
|
||||
modelTypeDisabled.value = false;
|
||||
//初始化模型提供者
|
||||
initModelProvider();
|
||||
dataIndex.value = 'list';
|
||||
modelNameAddOption.value = [];
|
||||
modelActivate.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
//初始化模型类型
|
||||
initModelTypeOption();
|
||||
|
||||
/**
|
||||
* 初始化 模型类型字典
|
||||
*/
|
||||
function initModelTypeOption() {
|
||||
initDictOptions('model_type').then((data) => {
|
||||
modelTypeOption.value = cloneDeep(data);
|
||||
//update-begin---author:wangshuai---date:2025-03-04---for: 解决页面tab刷新一次就多一个全部类型的选项---
|
||||
if(data[0].value != 'all'){
|
||||
modelTypeOption.value.unshift({
|
||||
text: '全部类型',
|
||||
value: 'all',
|
||||
});
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-04---for: 解决页面tab刷新一次就多一个全部类型的选项---
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉框值选中事件
|
||||
* @param value
|
||||
*/
|
||||
function handleChange(value) {
|
||||
if ('all' == value) {
|
||||
modelTypeList.value = model.data;
|
||||
return;
|
||||
}
|
||||
let data = model.data.filter((item) => {
|
||||
return item.type.includes(value);
|
||||
});
|
||||
modelTypeList.value = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化模型提供者
|
||||
*/
|
||||
function initModelProvider() {
|
||||
modelTypeList.value = model.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 供应商点击事件
|
||||
*
|
||||
* @param item
|
||||
*/
|
||||
function handleClick(item) {
|
||||
dataIndex.value = 'add';
|
||||
modelData.value = item;
|
||||
providerName.value = item.title;
|
||||
modelTypeAddOption.value = item.type;
|
||||
setTimeout(()=>{
|
||||
setFieldsValue({ 'provider': item.value, 'baseUrl': item.baseUrl })
|
||||
},100)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function save() {
|
||||
try {
|
||||
setModalProps({ confirmLoading: true });
|
||||
let values = await validate();
|
||||
let credential = {
|
||||
apiKey: values.apiKey,
|
||||
secretKey: values.secretKey
|
||||
}
|
||||
if(modelParamsRef.value){
|
||||
let modelParams = modelParamsRef.value.emitChange();
|
||||
if(modelParams){
|
||||
values.modelParams = JSON.stringify(modelParams);
|
||||
}
|
||||
}
|
||||
if(modelActivate.value){
|
||||
values.activateFlag = 1
|
||||
}else{
|
||||
values.activateFlag = 0;
|
||||
}
|
||||
values.credential = JSON.stringify(credential);
|
||||
//新增
|
||||
if (!values.id) {
|
||||
values.provider = modelData.value.value;
|
||||
await saveModel(values);
|
||||
closeModal();
|
||||
emit('success');
|
||||
} else {
|
||||
await editModel(values);
|
||||
closeModal();
|
||||
emit('success');
|
||||
}
|
||||
}catch(e){
|
||||
if(e.hasOwnProperty('errorFields')){
|
||||
activeKey.value = 1;
|
||||
}
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function cancel() {
|
||||
dataIndex.value = 'list';
|
||||
closeModal();
|
||||
emit('success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
async function test(onlyTest = false) {
|
||||
try {
|
||||
testLoading.value = true;
|
||||
let values = await validate();
|
||||
let credential = {
|
||||
apiKey: values.apiKey,
|
||||
secretKey: values.secretKey,
|
||||
};
|
||||
if (modelParamsRef.value) {
|
||||
let modelParams = modelParamsRef.value.emitChange();
|
||||
if (modelParams) {
|
||||
values.modelParams = JSON.stringify(modelParams);
|
||||
}
|
||||
}
|
||||
values.credential = JSON.stringify(credential);
|
||||
if (!values.provider) {
|
||||
values.provider = modelData.value.value;
|
||||
}
|
||||
//测试
|
||||
await testConn(values).then(async (result) => {
|
||||
if(onlyTest){
|
||||
$message.success('测试连接成功');
|
||||
return true;
|
||||
}
|
||||
modelActivate.value = true;
|
||||
await save();
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.hasOwnProperty('errorFields')) {
|
||||
activeKey.value = 1;
|
||||
}
|
||||
} finally {
|
||||
testLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型类型选择事件
|
||||
* @param value
|
||||
*/
|
||||
async function handleModelTypeChange(value) {
|
||||
await setFieldsValue({ modelName: '' });
|
||||
await clearValidate('modelName');
|
||||
await setFieldsValue({
|
||||
modelName: modelData.value[value+'DefaultValue']
|
||||
})
|
||||
modelNameAddOption.value = modelData.value[value];
|
||||
if(value === 'LLM'){
|
||||
modelParamsShow.value = true;
|
||||
}else{
|
||||
modelParamsShow.value = false;
|
||||
}
|
||||
if(value === "IMAGE" && modelData.value.baseImageUrl){
|
||||
setFieldsValue({ 'baseUrl': modelData.value.baseImageUrl })
|
||||
} else if(modelData.value.baseUrl) {
|
||||
setFieldsValue({ 'baseUrl': modelData.value.baseUrl })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择供应商
|
||||
*/
|
||||
function goToList() {
|
||||
if (dataIndex.value === 'add') {
|
||||
dataIndex.value = 'list';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标题
|
||||
* @param title
|
||||
*/
|
||||
function getTitle(title) {
|
||||
if(!title){
|
||||
return "暂无描述内容";
|
||||
}
|
||||
return title.replaceAll("\n","<br>")
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
modelTypeData,
|
||||
modelTypeOption,
|
||||
modelType,
|
||||
handleChange,
|
||||
modelTypeList,
|
||||
getImage,
|
||||
handleClick,
|
||||
dataIndex,
|
||||
providerName,
|
||||
save,
|
||||
cancel,
|
||||
registerForm,
|
||||
handleModelTypeChange,
|
||||
modelTypeAddOption,
|
||||
modelNameAddOption,
|
||||
goToList,
|
||||
modelTypeDisabled,
|
||||
activeKey,
|
||||
modelParams,
|
||||
modelParamsShow,
|
||||
modelActivate,
|
||||
modelParamsRef,
|
||||
filterOption,
|
||||
getTitle,
|
||||
test,
|
||||
testLoading,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.modal {
|
||||
padding: 12px 20px 20px 20px;
|
||||
.header {
|
||||
padding: 0 24px 24px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.header-select {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.add-header-title {
|
||||
color: #646a73;
|
||||
}
|
||||
}
|
||||
.model-content {
|
||||
.model-header {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
.header-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.header-text {
|
||||
width: calc(100% - 80px);
|
||||
overflow: hidden;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
.model-card {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
:deep(.ant-card .ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.jeecg-basic-modal-close){
|
||||
span{
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style lang="less">
|
||||
.ai-model-modal{
|
||||
.jeecg-basic-modal-close > span{
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<div class="model-params-popover">
|
||||
<div class="params" v-if="type === 'model'">
|
||||
<span style="font-size:14px">参数</span>
|
||||
<a-select value="加载预设" style="width: 96px" size="small" @change="onLoadPreset">
|
||||
<a-select-option v-for="(preset, idx) of presets" :value="idx" :key="idx">
|
||||
<a-space>
|
||||
<Icon :icon="preset.icon" />
|
||||
<span>{{ preset.name }}</span>
|
||||
</a-space>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<!-- 模型温度 -->
|
||||
<div class="setting-item" v-if="type === 'model'">
|
||||
<div class="label">
|
||||
<span>模型温度</span>
|
||||
<a-tooltip :title="tips.temperature">
|
||||
<Icon icon="ant-design:question-circle" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-switch v-model:checked="temperatureEnable" size="small"/>
|
||||
<a-slider v-bind="temperatureProps" v-model:value="model.temperature" :disabled="model['temperature'] === null"/>
|
||||
<a-input-number v-bind="temperatureProps" v-model:value="model.temperature" :disabled="model['temperature'] === null"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- 词汇属性 -->
|
||||
<div class="setting-item" v-if="type === 'model'">
|
||||
<div class="label">
|
||||
<span>词汇属性</span>
|
||||
<a-tooltip :title="tips.topP">
|
||||
<Icon icon="ant-design:question-circle" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-switch v-model:checked="topPEnable" size="small"/>
|
||||
<a-slider v-bind="topPProps" v-model:value="model.topP" :disabled="model['topP'] === null"/>
|
||||
<a-input-number v-bind="topPProps" v-model:value="model.topP" :disabled="model['topP'] === null"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- 话题属性 -->
|
||||
<div class="setting-item" v-if="type === 'model'">
|
||||
<div class="label">
|
||||
<span>话题属性</span>
|
||||
<a-tooltip :title="tips.presencePenalty">
|
||||
<Icon icon="ant-design:question-circle" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-switch v-model:checked="presencePenaltyEnable" size="small" />
|
||||
<a-slider v-bind="presencePenaltyProps" v-model:value="model.presencePenalty" :disabled="model['presencePenalty'] === null"/>
|
||||
<a-input-number v-bind="presencePenaltyProps" v-model:value="model.presencePenalty" :disabled="model['presencePenalty'] === null"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- 重复属性 -->
|
||||
<div class="setting-item" v-if="type === 'model'">
|
||||
<div class="label">
|
||||
<span>重复属性</span>
|
||||
<a-tooltip :title="tips.frequencyPenalty">
|
||||
<Icon icon="ant-design:question-circle" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-switch v-model:checked="frequencyPenaltyEnable" size="small" />
|
||||
<a-slider v-bind="frequencyPenaltyProps" v-model:value="model.frequencyPenalty" :disabled="model['frequencyPenalty'] === null"/>
|
||||
<a-input-number v-bind="frequencyPenaltyProps" v-model:value="model.frequencyPenalty" :disabled="model['frequencyPenalty'] === null"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- 最大回复 -->
|
||||
<div class="setting-item" v-if="type === 'model'">
|
||||
<div class="label">
|
||||
<span>最大回复</span>
|
||||
<a-tooltip :title="tips.maxTokens">
|
||||
<Icon icon="ant-design:question-circle" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-switch v-model:checked="maxTokensEnable" size="small" />
|
||||
<a-slider v-bind="maxTokensProps" v-model:value="model.maxTokens" :disabled="model['maxTokens'] === null"/>
|
||||
<a-input-number v-bind="maxTokensProps" v-model:value="model.maxTokens" :disabled="model['maxTokens'] === null"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- timeout 超时 -->
|
||||
<div class="setting-item" v-if="type === 'model'">
|
||||
<div class="label">
|
||||
<span>超时时间</span>
|
||||
<a-tooltip :title="tips.timeout">
|
||||
<Icon icon="ant-design:question-circle" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-switch v-model:checked="timeoutEnable" size="small" />
|
||||
<a-slider v-bind="timeoutProps" v-model:value="model.timeout" :disabled="model['timeout'] === null"/>
|
||||
<a-input-number v-bind="timeoutProps" v-model:value="model.timeout" :disabled="model['timeout'] === null"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- top k -->
|
||||
<div class="setting-item" v-if="type === 'knowledge'">
|
||||
<div class="label">
|
||||
<span>Top K</span>
|
||||
<a-tooltip :title="tips.topNumber">
|
||||
<Icon icon="ant-design:question-circle" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-switch v-model:checked="topNumberEnable" size="small" />
|
||||
<a-slider v-bind="topNumberProps" v-model:value="model.topNumber" :disabled="model['topNumber'] === null"/>
|
||||
<a-input-number v-bind="topNumberProps" v-model:value="model.topNumber" :disabled="model['topNumber'] === null"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- Score 阈值 -->
|
||||
<div class="setting-item" v-if="type === 'knowledge'">
|
||||
<div class="label">
|
||||
<span>Score 阈值</span>
|
||||
<a-tooltip :title="tips.similarity">
|
||||
<Icon icon="ant-design:question-circle" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-switch v-model:checked="similarityEnable" size="small" />
|
||||
<a-slider v-bind="similarityProps" v-model:value="model.similarity" :disabled="model['similarity'] === null"/>
|
||||
<a-input-number v-bind="similarityProps" v-model:value="model.similarity" :disabled="model['similarity'] === null"/>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { cloneDeep, omit } from 'lodash-es';
|
||||
|
||||
export default {
|
||||
name: 'AiModelSeniorForm',
|
||||
components: {},
|
||||
props: {
|
||||
modelParams: {
|
||||
type: Object,
|
||||
default: {}
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'model'
|
||||
}
|
||||
},
|
||||
emits: ['success', 'register', 'updateModel'],
|
||||
setup(props, { emit }) {
|
||||
// 预设参数
|
||||
const presets = [
|
||||
{
|
||||
name: '创意',
|
||||
icon: 'fxemoji:star',
|
||||
params: {
|
||||
temperature: 0.8,
|
||||
topP: 0.9,
|
||||
presencePenalty: 0.1,
|
||||
frequencyPenalty: 0.1,
|
||||
maxTokens: null,
|
||||
timeout: 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '平衡',
|
||||
icon: 'noto:balance-scale',
|
||||
params: {
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
presencePenalty: 0.2,
|
||||
frequencyPenalty: 0.3,
|
||||
maxTokens: null,
|
||||
timeout: 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '精确',
|
||||
icon: 'twemoji:direct-hit',
|
||||
params: {
|
||||
temperature: 0.2,
|
||||
topP: 0.7,
|
||||
presencePenalty: 0.5,
|
||||
frequencyPenalty: 0.5,
|
||||
maxTokens: null,
|
||||
timeout: 60,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 参数介绍
|
||||
const tips = {
|
||||
temperature: '值越大,回复内容越赋有多样性创造性、随机性;设为0根据事实回答,希望得到精准答案应该降低该参数;日常聊天建议0.5-0.8。',
|
||||
topP: '值越小,Ai生成的内容越单调也越容易理解;值越大,Ai回复的词汇围越大,越多样化。',
|
||||
presencePenalty: '值越大,越能够让Ai更好地控制新话题的引入,建议微调或不变。',
|
||||
frequencyPenalty: '值越大,越能够让Ai更好地避免重复之前说过的话,建议微调或不变。',
|
||||
maxTokens:
|
||||
'设置Ai最大回复内容大小,会影响返回结果的长度。普通聊天建议500-800;短文生成建议800-2000;代码生成建议2000-3600;长文生成建议4000左右(或选择长回复模型)',
|
||||
topNumber: '用于筛选与用户问题相似度最高的文本片段。系统同时会根据选用模型上下文窗口大小动态调整分段数量。',
|
||||
similarity: '用于设置文本片段筛选的相似度阅值。',
|
||||
timeout: '等待AI响应的最长时间,单位为秒。'
|
||||
};
|
||||
|
||||
// 参数:温度
|
||||
const temperatureProps = ref<any>({
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
});
|
||||
|
||||
// 参数:词汇属性
|
||||
const topPProps = ref<any>({
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
});
|
||||
// 参数:话题属性
|
||||
const presencePenaltyProps = ref<any>({
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
});
|
||||
// 参数:重复属性
|
||||
const frequencyPenaltyProps = ref<any>({
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
});
|
||||
// 参数:最大回复
|
||||
const maxTokensProps = ref<any>({
|
||||
min: 1,
|
||||
max: 16000,
|
||||
step: 1,
|
||||
});
|
||||
|
||||
// 参数:topk
|
||||
const topNumberProps = ref<any>({
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
});
|
||||
|
||||
// 参数:Score 阈值
|
||||
const similarityProps = ref<any>({
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
});
|
||||
|
||||
//参数:超时时间
|
||||
const timeoutProps = ref<any>({
|
||||
min: 1,
|
||||
step: 1,
|
||||
max: 3600,
|
||||
})
|
||||
|
||||
|
||||
//参数对象
|
||||
const model = ref<any>(props.modelParams || {})
|
||||
|
||||
//模型温度是否勾选
|
||||
const temperatureEnable = computed({
|
||||
get:()=> model.value.temperature != null,
|
||||
set:(value) => model.value.temperature = !value? null: 0.7
|
||||
});
|
||||
|
||||
//词汇属性是否勾选
|
||||
const topPEnable = computed({
|
||||
get:()=> model.value.topP != null,
|
||||
set:(value) => model.value.topP = !value? null: 0
|
||||
});
|
||||
|
||||
//词汇属性是否勾选
|
||||
const presencePenaltyEnable = computed({
|
||||
get:()=> model.value.presencePenalty != null,
|
||||
set:(value) => model.value.presencePenalty = !value? null: 0
|
||||
});
|
||||
|
||||
//重复属性是否勾选
|
||||
const frequencyPenaltyEnable = computed({
|
||||
get:()=> model.value.frequencyPenalty != null,
|
||||
set:(value) => model.value.frequencyPenalty = !value? null: 0
|
||||
});
|
||||
|
||||
//最大回复
|
||||
const maxTokensEnable = computed({
|
||||
get:()=> model.value.maxTokens != null,
|
||||
set:(value) => model.value.maxTokens = !value? null: 520
|
||||
});
|
||||
|
||||
//top k
|
||||
const topNumberEnable = computed({
|
||||
get:()=> model.value.topNumber != null,
|
||||
set:(value) => model.value.topNumber = !value? null: 4
|
||||
});
|
||||
|
||||
//Score 阈值
|
||||
const similarityEnable = computed({
|
||||
get:()=> model.value.similarity != null,
|
||||
set:(value) => model.value.similarity = !value? null: 0.74
|
||||
});
|
||||
|
||||
//timeout超时时间
|
||||
const timeoutEnable = computed({
|
||||
get:()=> model.value.timeout != null,
|
||||
set:(value) => model.value.timeout = !value? null: 60
|
||||
});
|
||||
|
||||
// 加载预设
|
||||
function onLoadPreset(idx: number) {
|
||||
const preset = presets[idx];
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
model.value = preset.params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新参数
|
||||
*
|
||||
* @param model
|
||||
*/
|
||||
function emitChange() {
|
||||
return model.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置modal值
|
||||
* @param values
|
||||
*/
|
||||
function setModalParams(values){
|
||||
model.value = values
|
||||
}
|
||||
|
||||
return {
|
||||
presets,
|
||||
onLoadPreset,
|
||||
tips,
|
||||
temperatureProps,
|
||||
topPProps,
|
||||
presencePenaltyProps,
|
||||
model,
|
||||
frequencyPenaltyProps,
|
||||
temperatureEnable,
|
||||
maxTokensProps,
|
||||
emitChange,
|
||||
topPEnable,
|
||||
presencePenaltyEnable,
|
||||
frequencyPenaltyEnable,
|
||||
maxTokensEnable,
|
||||
topNumberEnable,
|
||||
topNumberProps,
|
||||
similarityEnable,
|
||||
similarityProps,
|
||||
setModalParams,
|
||||
timeoutEnable,
|
||||
timeoutProps,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.model-params-popover {
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
.params{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.setting-item{
|
||||
margin-top: 10px;
|
||||
}
|
||||
.setting-item .label {
|
||||
> span {
|
||||
vertical-align: middle;
|
||||
|
||||
&.app-iconify {
|
||||
cursor: help;
|
||||
color: #888888;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-space {
|
||||
.ant-slider {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
.ant-input-number {
|
||||
width: 110px;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"title": "Anthropic",
|
||||
"value": "ANTHROPIC",
|
||||
"LLM": [
|
||||
{"label": "claude-sonnet-4-20250514", "value": "claude-sonnet-4-20250514","descr": "【Claude 4系列】Claude Sonnet 4具有卓越推理能力的高性能模型。\n\n支持文本和图像输入,文本输出,拥有200k上下文窗口(1M上下文测试版可用)。","type": "text,image"},
|
||||
{"label": "claude-opus-4-20250514", "value": "claude-opus-4-20250514","descr": "【Claude 4系列】Claude Opus 4是最强大、最有能力的模型。\n\n支持文本和图像输入,文本输出,拥有200k上下文窗口,卓越的推理能力。","type": "text,image"},
|
||||
{"label": "claude-3-7-sonnet-20250219", "value": "claude-3-7-sonnet-20250219","descr": "【Claude 3.7系列】Claude Sonnet 3.7中型模型,具备卓越的推理能力和效率。\n\n适合企业工作负载和大规模AI部署。","type": "text,image"},
|
||||
{"label": "claude-3-5-sonnet-20241022", "value": "claude-3-5-sonnet-20241022","descr": "Claude 3.5 Sonnet是Anthropic最强大的AI模型。\n\n在编程、多步骤工作流、图表解释等复杂任务中表现出色。支持200k上下文长度,支持8k最大输出。","type": "text,image"},
|
||||
{"label": "claude-3-5-haiku-20241022", "value": "claude-3-5-haiku-20241022","descr": "【快速模型】Claude 3.5 Haiku是Anthropic最快的AI模型。\n\n响应速度快,成本较低,适合高频交互场景。支持200k上下文长度,支持8k最大输出。","type": "text,image"},
|
||||
{"label": "claude-3-opus-20240229", "value": "claude-3-opus-20240229","descr": "Claude 3 Opus是Claude 3系列中性能最强的模型。\n\n在高度复杂的任务上表现出色,如编写高质量代码、数学推理等。支持200k上下文长度,支持4k最大输出。","type": "text,image"},
|
||||
{"label": "claude-3-sonnet-20240229", "value": "claude-3-sonnet-20240229","descr": "Claude 3 Sonnet在智能和速度之间取得了良好平衡。\n\n适合企业工作负载和大规模AI部署。支持200k上下文长度,支持4k最大输出。","type": "text,image"},
|
||||
{"label": "claude-3-haiku-20240307", "value": "claude-3-haiku-20240307","descr": "Claude 3 Haiku是Claude 3系列中最快的模型。\n\n提供接近即时的响应,适合无缝AI体验。支持200k上下文长度,支持4k最大输出。","type": "text,image"}
|
||||
],
|
||||
"type": ["LLM"],
|
||||
"baseUrl": "https://api.anthropic.com/v1",
|
||||
"LLMDefaultValue": "claude-3-5-sonnet-20241022"
|
||||
},
|
||||
{
|
||||
"title": "DeepSeek",
|
||||
"value": "DEEPSEEK",
|
||||
"LLM": [
|
||||
{"label": "deepseek-reasoner", "value": "deepseek-reasoner","descr": "【官方模型】深度求索 新推出的推理模型R1满血版\n火便全球。\n支持64k上下文,其中支持8k最大回复。","type": "text"},
|
||||
{"label":"deepseek-chat", "value": "deepseek-chat","descr": "最强开源 MoE 模型 DeepSeek-V3,全球首个在代码、数学能力上与GPT-4-Turbo争锋的模型,在代码、数学的多个榜单上位居全球第二;","type": "text"}
|
||||
],
|
||||
"type": ["LLM"],
|
||||
"baseUrl": "https://api.deepseek.com/v1",
|
||||
"LLMDefaultValue": "deepseek-chat"
|
||||
},
|
||||
{
|
||||
"title": "Ollama",
|
||||
"value": "OLLAMA",
|
||||
"LLM": [
|
||||
{"label": "llama2", "value": "llama2"},
|
||||
{"label": "llama2:13b", "value": "llama2:13b"},
|
||||
{"label": "llama2:70b", "value": "llama2:70b"},
|
||||
{"label": "llama2-chinese:13b", "value": "llama2-chinese:13b"},
|
||||
{"label": "llama3:8b", "value": "llama3:8b"},
|
||||
{"label": "llama3:70b", "value": "llama3:70b"},
|
||||
{"label": "qwen:0.5b", "value": "qwen:0.5b"},
|
||||
{"label": "qwen:1.8b", "value": "qwen:1.8b"},
|
||||
{"label": "qwen:4b", "value": "qwen:4b"},
|
||||
{"label": "qwen:7b", "value": "qwen:7b"},
|
||||
{"label": "qwen:14b", "value": "qwen:14b"},
|
||||
{"label": "qwen:32b", "value": "qwen:32b"},
|
||||
{"label": "qwen:72b", "value": "qwen:72b"},
|
||||
{"label": "qwen:110b", "value": "qwen:110b"},
|
||||
{"label": "qwen2:72b-instruct", "value": "qwen2:72b-instruct"},
|
||||
{"label": "qwen2:57b-a14b-instruct", "value": "qwen2:57b-a14b-instruct"},
|
||||
{"label": "qwen2:7b-instruct", "value": "qwen2:7b-instruct"},
|
||||
{"label": "qwen2.5:72b-instruct", "value": "qwen2.5:72b-instruct"},
|
||||
{"label": "qwen2.5:32b-instruct", "value": "qwen2.5:32b-instruct"},
|
||||
{"label": "qwen2.5:14b-instruct", "value": "qwen2.5:14b-instruct"},
|
||||
{"label": "qwen2.5:7b-instruct", "value": "qwen2.5:7b-instruct"},
|
||||
{"label": "qwen2.5:1.5b-instruct", "value": "qwen2.5:1.5b-instruct"},
|
||||
{"label": "qwen2.5:0.5b-instruct", "value": "qwen2.5:0.5b-instruct"},
|
||||
{"label": "qwen2.5:3b-instruct", "value": "qwen2.5:3b-instruct"},
|
||||
{"label": "phi3", "value": "phi3"}
|
||||
],
|
||||
"EMBED": [
|
||||
{"label": "nomic-embed-text", "value": "nomic-embed-text"}
|
||||
],
|
||||
"type": ["LLM", "EMBED"],
|
||||
"baseUrl": "http://localhost:11434",
|
||||
"LLMDefaultValue": "llama2",
|
||||
"EMBEDDefaultValue": "nomic-embed-text"
|
||||
},
|
||||
{
|
||||
"title": "OpenAI",
|
||||
"value": "OPENAI",
|
||||
"LLM": [
|
||||
{"label": "gpt-3.5-turbo", "value": "gpt-3.5-turbo","descr": "纯官方高速GPT3.5系列,目前指向gpt-35-turbo-0125模型,最大回复小于4k。\n综合能力强,过去使用最广泛的文本模型。", "type": "text"
|
||||
},
|
||||
{"label": "gpt-4", "value": "gpt-4","descr": "纯官方GPT4系列。知识库截止于2021年,价格适中,具有中等参数,比gpt-4turbo系列略强。","type": "text"},
|
||||
{"label": "gpt-4o", "value": "gpt-4o","descr": "GPT-4o,是openai的新旗舰型号,支持文本和图片分析。\n\n是迈向更自然的人机交互的一步——它接受文本和图像的任意组合作为输入,并生成文本和图像输出的任意组合。","type": "text,image"},
|
||||
{"label": "gpt-4o-mini", "value": "gpt-4o-mini","descr": "GPT-4o mini是目前性价比最高的小参数模型,性能介于GPT3.5~GPT4o之间。\n\n成本相比GPT-3.5 Turbo便宜60%以上,支持50种不同语言,用于替代GPT-3.5版本的模型。\n\n4o-mini的图像分析价格和4o差不多,如果有图像分析需求还是4o更好一些。\n\n当前指向 gpt-4o-mini-2024-07-18","type": "text,image"},
|
||||
{"label": "gpt-4-turbo", "value": "gpt-4-turbo","descr": "纯官方GPT4系列,支持文本和图片分析,最大回复4k,openai于2024-4-9新增的模型,知识库更新于2023年12月。提高了写作、数学、逻辑推理和编码能力。当前指向gpt-4-turbo-2024-04-09","type": "text,image"},
|
||||
{"label": "gpt-4-turbo-preview", "value": "gpt-4-turbo-preview","descr": "纯官方GPT4系列,最大回复4k,知识库更新于2023年4月。当前指向gpt-4-0125-preview","type": "text"},
|
||||
{"label": "gpt-3.5-turbo-0125", "value": "gpt-3.5-turbo-0125","descr": "openai于2024年1月25号更新的gpt-3.5模型,最大回复4k。\n\n综合能力强,过去使用最广泛的文本模型。","type": "text"},
|
||||
{"label": "gpt-3.5-turbo-1106", "value": "gpt-3.5-turbo-1106","descr": "openai于2023年11月6号更新的gpt-3.5模型,最大回复4k。属于即将被淘汰的模型。\n\n建议使用gpt-3.5-turbo或gpt-4o-mini","type": "text"},
|
||||
{"label": "gpt-3.5-turbo-0613", "value": "gpt-3.5-turbo-0613","descr": "通过微调后可以更准确地按照用户的指示进行操作,生成更简洁和针对性的输出。它不仅可以用于文本生成,还可以通过函数调用功能与其他系统和API进行集成,实现更复杂的任务自动化","type": "text"},
|
||||
{"label": "gpt-4o-2024-05-13", "value": "gpt-4o-2024-05-13","descr": "GPT-4o,是openai的新旗舰型号,支持文本和图片分析。\n\n是迈向更自然的人机交互的一步——它接受文本和图像的任意组合作为输入,并生成文本和图像输出的任意组合。\n\n该模型为初代的4o模型","type": "text,image"},
|
||||
{"label": "gpt-4-turbo-2024-04-09", "value": "gpt-4-turbo-2024-04-09","descr": "纯官方GPT4系列,支持文本和图片分析,最大回复4k,openai于2024-4-9新增的模型,提高了写作、数学、逻辑推理和编码能力。知识库更新于2023年12月。","type": "text,image"},
|
||||
{"label": "gpt-4-0125-preview", "value": "gpt-4-0125-preview","descr": "纯官方GPT4系列,最大回复4k,知识库更新于2023年4月。当前与gpt-4-turbo-preview属于同一模型","type": "text"},
|
||||
{"label": "gpt-4-1106-preview", "value": "gpt-4-1106-preview","descr": "纯官方GPT4系列,最大回复4k,知识库更新于2023年4月。正在逐渐被新的模型gpt-4-turbo和gpt-4-turbo-preview取代。","type": "text"}
|
||||
],
|
||||
"EMBED": [
|
||||
{"label": "text-embedding-ada-002", "value": "text-embedding-ada-002","descr": "用于生成文本嵌入的模型。文本嵌入是将文本转换为数值形式(通常是向量),以便可以用于机器学习模型。","type": "vector,embeddings"},
|
||||
{"label": "text-embedding-3-small", "value": "text-embedding-3-small","descr": "用于生成文本的嵌入表示,网络结构较小,计算资源需求较低。虽然可能不如\"large\"版本那样精准,但它更适合于资源受限的环境或需要更快速处理的任务。","type": "vector,embeddings"},
|
||||
{"label": "text-embedding-3-large", "value": "text-embedding-3-large","descr": "用于生成文本的嵌入表示,即将文本转换为高维空间中的点,这些点的距离可以表示文本之间的相似度。有较大的网络结构,能够捕捉更丰富的语言特征,适用于需要高质量文本相似度或分类任务的场景。","type": "vector,embeddings"}
|
||||
],
|
||||
"IMAGE": [
|
||||
{"label": "gpt-image-1.5", "value": "gpt-image-1.5","descr": "openAI强大的图片生成模型,支持图像生成与编辑功能,生图图片支持中文文字。相比上一版本,拥有更好的指令跟踪和对提示词的遵循。","type": "imageGen"},
|
||||
{"label": "gpt-image-1", "value": "gpt-image-1","descr": "是一个图片生成模型,支持图像生成与编辑功能,生图图片支持中文文字。","type": "imageGen"},
|
||||
{"label": "dall-e-2", "value": "dall-e-2","descr": "是OpenAI推出的文本生成图像系统","type": "imageGen"},
|
||||
{"label": "dall-e-3", "value": "dall-e-3","descr": "是OpenAI开发的第三代文本到图像生成模型,提升了图像细节处理和艺术风格迁移能力","type": "imageGen"}
|
||||
],
|
||||
"type": ["LLM", "EMBED","IMAGE"],
|
||||
"baseUrl": "https://api.openai.com/v1/",
|
||||
"LLMDefaultValue": "gpt-4o-mini",
|
||||
"EMBEDDefaultValue": "text-embedding-ada-002",
|
||||
"IMAGEDefaultValue": "gpt-image-1.5"
|
||||
},
|
||||
{
|
||||
"title": "通义千问",
|
||||
"value": "QWEN",
|
||||
"LLM": [
|
||||
{"label": "qwen-turbo", "value": "qwen-turbo","descr": "通义千问超大规模语言模型,支持中文、英文等不同语言输入。适合文本创作、文本处理、编程辅助、翻译服务、对话模拟。","type": "text"},
|
||||
{"label": "qwen-plus", "value": "qwen-plus","descr": "通义千问超大规模语言模型,支持中文、英文等不同语言输入。适合文本创作、文本处理、编程辅助、翻译服务、对话模拟。","type": "text"},
|
||||
{"label": "qwen-max", "value": "qwen-max","descr": "暂无描述内容!","type": "text"}
|
||||
],
|
||||
"EMBED": [
|
||||
{"label": "text-embedding-v2", "value": "text-embedding-v2","descr": "是一种将文本数据转换为向量的技术,通过深度学习模型将文本的语义信息嵌入到高维向量空间中。这些向量不仅能表达文本内容,还能捕捉文本之间的相似性和关系,从而让计算机高效地进行文本检索、分类、聚类等任务。","type": "vector"}
|
||||
],
|
||||
"IMAGE": [
|
||||
{ "label": "wan2.2-t2i-plus", "value": "wan2.2-t2i-plus","descr": "全新升级的通义万相2.2文生图,更快的生成速度。在生成图像创意性、稳定性、写实质感方面全面升级,指令遵循更强,原生支持多种风格。支持最大200万像素生成,支持智能提示词改写等。","type": "imageGen"},
|
||||
{ "label": "wan2.2-t2i-flash", "value": "wan2.2-t2i-flash","descr": "全新升级的通义万相2.2文生图,更快的生成速度。在生成图像创意性、稳定性、写实质感方面全面升级,指令遵循更强,原生支持多种风格。支持最大200万像素生成,支持智能提示词改写等。","type": "imageGen"},
|
||||
{ "label": "wan2.5-i2i-preview", "value": "wan2.5-i2i-preview","descr": "是万相2.5系列中的图像编辑模型,专注于通用图像编辑任务,支持单图编辑和多图融合功能","type": "imageGen"},
|
||||
{ "label": "wanx2.1-imageedit", "value": "wanx2.1-imageedit","descr": "是阿里云百炼平台提供的通用图像编辑模型,适用于多种图像处理任务,如风格迁移、内容编辑、尺寸优化等","type": "imageGen"},
|
||||
{ "label": "wanx-v1", "value": "wanx-v1","descr": "是通义万相系列中的文本生成图像模型,支持中英文双语输入和多种图像风格生成","type": "imageGen"}
|
||||
],
|
||||
"type": ["LLM", "EMBED","IMAGE"],
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/api/v1/services/",
|
||||
"baseImageUrl": "https://dashscope.aliyuncs.com/api/v1/",
|
||||
"LLMDefaultValue": "qwen-plus",
|
||||
"EMBEDDefaultValue": "text-embedding-v2",
|
||||
"IMAGEDefaultValue": "wan2.2-t2i-plus"
|
||||
},
|
||||
{
|
||||
"title": "千帆大模型",
|
||||
"value": "QIANFAN",
|
||||
"LLM": [
|
||||
{"label": "ERNIE-Bot", "value": "ERNIE-Bot","descr": "是百度推出的一款知识增强大语言模型,主要用于与人对话互动、回答问题、协助创作,帮助人们高效便捷地获取信息、知识和灵感","type": "text"},
|
||||
{"label": "ERNIE-Bot 4.0", "value": "ERNIE-Bot 4.0","descr": "百度自行研发的文心产业级知识增强大语言模型4.0版本\n\n实现了基础模型的全面升级,在理解、生成、逻辑和记忆能力上相对ERNIE 3.5都有着显著提升,支持5K输入+2K输出。","type": "text"},
|
||||
{"label": "ERNIE-Bot-8K", "value": "ERNIE-Bot-8K","descr": "主要用于数据分析场景,特别是在企业数据分析中表现出色。ERNIE-Bot-8K是百度文心大模型的一个版本,具有模型效果优、生成能力强、应用门槛低等独特优势。","type": "text"},
|
||||
{"label": "ERNIE-Bot-turbo", "value": "ERNIE-Bot-turbo","descr": "是一个大语言模型,主要用于对话问答、内容创作生成等任务。它是百度自行研发的大语言模型,覆盖了海量中文数据,具有更强的对话问答和内容创作生成能力","type": "text"},
|
||||
{"label": "ERNIE-Speed-128K", "value": "ERNIE-Speed-128K","descr": "是一款基于Transformer结构的轻量级语言模型,旨在满足实时数据处理的需求。它具有高效、低延迟和高准确性的特点,广泛应用于自然语言处理、信息检索和文本分类等领域","type": "text"},
|
||||
{"label": "EB-turbo-AppBuilder", "value": "EB-turbo-AppBuilder","descr": "主要用于企业级应用场景,如智能客服、内容创作和知识问答等任务。它是基于文心高性能大语言模型ERNIE-Bot-turbo构建的,针对企业特定需求进行了深度的场景效果优化和输出格式定制,因此在满足企业特定需求方面具有更高的灵活性和实用性","type": "text"},
|
||||
{"label": "Yi-34B-Chat", "value": "Yi-34B-Chat","descr": "Yi-34B-Chat是一款基于Transformer架构的生成式预训练语言模型,它拥有340亿个参数,使其在处理自然语言任务时表现出了强大的能力。","type": "text"},
|
||||
{"label": "BLOOMZ-7B", "value": "BLOOMZ-7B","descr": "是一个用于生成文本序列的自回归模型,它可以进行多语言处理,支持46种语言和13种编程语言。BLOOMZ-7B是BLOOM模型的一个调优版本,具有更出色的泛化和零样本学习能力,适用于多种任务和场景","type": "text"},
|
||||
{"label": "Qianfan-BLOOMZ-7B-compressed", "value": "Qianfan-BLOOMZ-7B-compressed","descr": "是千帆团队在BLOOMZ-7B基础上的压缩版本,融合量化、稀疏化等技术,显存占用降低30%以上。","type": "text"},
|
||||
{"label": "Mixtral-8x7B-Instruct", "value": "Mixtral-8x7B-Instruct","descr": "由Mistral AI发布的首个高质量稀疏专家混合模型 (MOE),模型由8个70亿参数专家模型组成,在多个基准测试中表现优于Llama-2-70B及GPT3.5,能够处理32K上下文,在代码生成任务中表现尤为优异。","type": "text"},
|
||||
{"label": "Llama-2-7b-chat", "value": "Llama-2-7b-chat","descr": "由Meta AI研发并开源,在编码、推理及知识应用等场景表现优秀,Llama-2-7b-chat是高性能原生开源版本,适用于对话场景。","type": "text"},
|
||||
{"label": "Llama-2-13b-chat", "value": "Llama-2-13b-chat","descr": "由Meta AI研发并开源,在编码、推理及知识应用等场景表现优秀,Llama-2-13b-chat是性能与效果均衡的原生开源版本,适用于对话场景。","type": "text"},
|
||||
{"label": "Llama-2-70b-chat", "value": "Llama-2-70b-chat","descr": "由Meta AI研发并开源,在编码、推理及知识应用等场景表现优秀,Llama-2-70b-chat是高精度效果的原生开源版本。","type": "text"},
|
||||
{"label": "Qianfan-Chinese-Llama-2-7B", "value": "Qianfan-Chinese-Llama-2-7B","descr": "是千帆团队在Llama-2-7b基础上的中文增强版本,在CMMLU、C-EVAL等中文数据集上表现优异。","type": "text"},
|
||||
{"label": "ChatGLM2-6B-32K", "value": "ChatGLM2-6B-32K","descr": "是在ChatGLM2-6B的基础上进一步强化了对于长文本的理解能力,能够更好的处理最多32K长度的上下文。","type": "text"},
|
||||
{"label": "AquilaChat-7B", "value": "AquilaChat-7B","descr": "是由智源研究院研发,基于Aquila-7B训练的对话模型,支持流畅的文本对话及多种语言类生成任务,通过定义可扩展的特殊指令规范,实现 AquilaChat对其它模型和工具的调用,且易于扩展。","type": "text"}
|
||||
],
|
||||
"EMBED": [
|
||||
{"label": "Embedding-V1", "value": "Embedding-V1","descr": "主要用于将离散对象(如文本、图像等)映射为连续的数值向量,以便于计算机处理和机器学习模型的训练和使用","type": "vector,embeddings"},
|
||||
{"label": "tao-8k", "value": "tao-8k","descr": "是由Huggingface开发者amu研发并开源的长文本向量表示模型,支持8k上下文长度,模型效果在C-MTEB上居前列,是当前最优的中文长文本embeddings模型之一","type": "vector"},
|
||||
{"label": "bge-large-zh", "value": "bge-large-zh","descr": "是由智源研究院研发的中文版文本表示模型,可将任意文本映射为低维稠密向量,以用于检索、分类、聚类或语义匹配等任务,并可支持为大模型调用外部知识。","type": "vector"},
|
||||
{"label": "bge-large-en", "value": "bge-large-en","descr": "是由智源研究院研发的英文版文本表示模型,可将任意文本映射为低维稠密向量,以用于检索、分类、聚类或语义匹配等任务,并可支持为大模型调用外部知识。","type": "vector"}
|
||||
],
|
||||
"type": ["LLM", "EMBED"],
|
||||
"baseUrl": "https://aip.baidubce.com",
|
||||
"LLMDefaultValue": "Yi-34B-Chat",
|
||||
"EMBEDDefaultValue": "Embedding-V1"
|
||||
},
|
||||
{
|
||||
"title": "智谱AI",
|
||||
"value": "ZHIPU",
|
||||
"LLM": [
|
||||
{"label": "glm-4", "value": "glm-4","descr": "是一个多模态大语言模型,主要用于处理复杂的指令和任务,支持长文本处理、多模态理解和文生图等功能","type": "text,image"},
|
||||
{"label": "glm-4v", "value": "glm-4v","descr": "智谱:多模态模型\n\n更懂中文的视觉理解、文生图等多模态模型能力。准确理解各任务场景语言描述及指令,更精确的完成多模态理解类任务,或生成高质量的图片、视频等多模态内容。","type": "text,image"},
|
||||
{"label": "glm-4-flash", "value": "glm-4-flash","descr": "该模型官方免费,主要用于处理多种自然语言处理任务,包括智能对话助手、辅助论文翻译、ppt及会议内容生产、网页智能搜索、数据生成和抽取、网页解析、智能规划和决策、辅助科研等场景","type": "text"},
|
||||
{"label": "glm-3-turbo", "value": "glm-3-turbo","descr": "是一种基于transformer结构的语言模型,由智谱AI推出。其主要特点包括使用三层transformer结构、采用Turbo机制以实时生成文本、处理长文本输入并具有强大的语言理解能力","type": "text"}
|
||||
],
|
||||
"EMBED": [
|
||||
{"label": "Embedding-3", "value": "Embedding-3","descr": "主要用于文本搜索、聚类、推荐等任务。它通过将文本映射到低维向量空间,使得文本之间的语义关系可以通过向量之间的距离或相似度来衡量,从而支持各种基于向量的应用。","type": "vector"},
|
||||
{"label": "Embedding-2", "value": "Embedding-2","descr": "用于将高维离散数据映射到低维连续数值向量中,以便机器学习模型能够更好地处理和理解这些数据","type": "vector"}
|
||||
],
|
||||
"IMAGE": [
|
||||
{"label": "CogView-4", "value": "CogView-4","descr": "智谱首个支持生成汉字的开源文生图模型,在语义理解、图像生成质量、中英文字生成能力等方面全面提升,支持任意长度的中英双语输入,能够生成在给定范围内的任意分辨率图像。","type": "imageGen"},
|
||||
{"label": "Cogview-3-Flash", "value": "Cogview-3-Flash","descr": "是智谱推出的免费图像生成模型,能够根据用户指令生成符合要求且美学评分更高的图像。CogView-3-Flash 主要应用于艺术创作、设计参考、游戏开发、虚拟现实等领域,帮助用户快速实现从文本到图像的转换需求。","type": "imageGen"}
|
||||
],
|
||||
"type": ["LLM", "EMBED", "IMAGE"],
|
||||
"baseUrl": "https://open.bigmodel.cn",
|
||||
"LLMDefaultValue": "glm-4-flash",
|
||||
"EMBEDDefaultValue": "Embedding-2",
|
||||
"IMAGEDefaultValue": "CogView-4"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/OpenAi.png
Normal file
|
After Width: | Height: | Size: 867 B |
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/anthropic.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/deepspeek.png
Normal file
|
After Width: | Height: | Size: 923 B |
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/ollama.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/qianfan.png
Normal file
|
After Width: | Height: | Size: 910 B |
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/qianwen.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
jeecgboot-vue3/src/views/super/airag/aimodel/icon/zhipuai.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
71
jeecgboot-vue3/src/views/super/airag/aimodel/model.api.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
enum Api {
|
||||
list = '/airag/airagModel/list',
|
||||
save = '/airag/airagModel/add',
|
||||
testConn = '/airag/airagModel/test',
|
||||
delete = '/airag/airagModel/delete',
|
||||
queryById = '/airag/airagModel/queryById',
|
||||
edit = '/airag/airagModel/edit',
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询AI模型
|
||||
* @param params
|
||||
*/
|
||||
export const list = (params) => {
|
||||
return defHttp.get({ url: Api.list, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据id查询AI模型
|
||||
* @param params
|
||||
*/
|
||||
export const queryById = (params) => {
|
||||
return defHttp.get({ url: Api.queryById, params }, { isTransformResponse: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增AI模型
|
||||
*
|
||||
* @param params
|
||||
*/
|
||||
export const saveModel = (params) => {
|
||||
return defHttp.post({ url: Api.save, params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 编辑AI模型
|
||||
*
|
||||
* @param params
|
||||
*/
|
||||
export const editModel = (params) => {
|
||||
return defHttp.put({ url: Api.edit, params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试链接
|
||||
*
|
||||
* @param params
|
||||
*/
|
||||
export const testConn = (params) => {
|
||||
return defHttp.post({ url: Api.testConn, params, timeout: 2*60*1000 });
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除数据权限
|
||||
*/
|
||||
export const deleteModel = (params, handleSuccess) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '是否删除名称为' + params.name + '的模型吗?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
return defHttp.delete({ url: Api.delete, params }, { joinParamsToUrl: true }).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
97
jeecgboot-vue3/src/views/super/airag/aimodel/model.data.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { FormSchema } from '@/components/Form';
|
||||
|
||||
import anthropic from './icon/anthropic.png';
|
||||
import deepspeek from './icon/deepspeek.png';
|
||||
import ollama from './icon/ollama.png';
|
||||
import OpenAi from './icon/OpenAi.png';
|
||||
import qianfan from './icon/qianfan.png';
|
||||
import qianwen from './icon/qianwen.png';
|
||||
import zhipuai from './icon/zhipuai.png';
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 表单
|
||||
*/
|
||||
export const formSchema: FormSchema[] = [
|
||||
{
|
||||
label: 'id',
|
||||
field: 'id',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: '模型名称',
|
||||
field: 'name',
|
||||
required: true,
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
label: '模型类型',
|
||||
field: 'modelType',
|
||||
slot: 'modelType',
|
||||
required: true,
|
||||
component: 'Select',
|
||||
},
|
||||
{
|
||||
label: '基础模型',
|
||||
field: 'modelName',
|
||||
required: true,
|
||||
slot: 'modelName',
|
||||
component: 'Select',
|
||||
},
|
||||
{
|
||||
label: 'API域名',
|
||||
field: 'baseUrl',
|
||||
required: true,
|
||||
component: 'Input'
|
||||
},
|
||||
{
|
||||
label: 'API Key',
|
||||
field: 'apiKey',
|
||||
required: true,
|
||||
component: 'InputPassword',
|
||||
componentProps: {
|
||||
autocomplete: 'new-password',
|
||||
},
|
||||
ifShow: ({ values }) => {
|
||||
if(values.provider==="OLLAMA"){
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Secret Key',
|
||||
field: 'secretKey',
|
||||
required: true,
|
||||
component: 'InputPassword',
|
||||
ifShow: ({ values }) => {
|
||||
if(values.provider==='DEEPSEEK' || values.provider==="OLLAMA" || values.provider==="OPENAI"
|
||||
|| values.provider==="ZHIPU" || values.provider==="QWEN" || values.provider==="ANTHROPIC"){
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '供应者',
|
||||
field: 'provider',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 图片路径映射
|
||||
*
|
||||
* @param name
|
||||
*/
|
||||
export const imageList = ref<any>({
|
||||
ANTHROPIC: anthropic,
|
||||
DEEPSEEK: deepspeek,
|
||||
OLLAMA: ollama,
|
||||
OPENAI: OpenAi,
|
||||
QIANFAN: qianfan,
|
||||
QWEN: qianwen,
|
||||
ZHIPU: zhipuai,
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FormSchema } from '@/components/Form';
|
||||
|
||||
export const formSchema: FormSchema[] = [
|
||||
{
|
||||
field: 'drawModelId',
|
||||
label: '模型',
|
||||
component: 'JDictSelectTag',
|
||||
required: true,
|
||||
helpMessage: [
|
||||
'1、需要选择在模型中已有的图像模型',
|
||||
'2、智普语言模型不支持尺寸设置',
|
||||
"3、openAi旧版模型如(dall-e-2)需要选择尺寸,新版模型直接输入'竖版: 9:16即可'",
|
||||
'4、当前只有千问万象模型(wanx2.1-imageedit,wan2.5-i2i-preview)支持图生图',
|
||||
'5、wan2.5-i2i-preview支持多张图片',
|
||||
'6、当前文生图openAi效果最佳',
|
||||
],
|
||||
componentProps: {
|
||||
dictCode: "airag_model where model_type = 'IMAGE' and activate_flag = 1,name,id",
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'content',
|
||||
label: '提示词',
|
||||
component: 'InputTextArea',
|
||||
required: true,
|
||||
componentProps: {
|
||||
rows: 10,
|
||||
placeholder: '请输入提示词,例如:一只可爱的猫咪,赛博朋克风格',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'imageUrl',
|
||||
label: '参考图',
|
||||
component: 'JImageUpload',
|
||||
componentProps: {
|
||||
fileMax: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'imageSize',
|
||||
label: '图片尺寸',
|
||||
component: 'Select',
|
||||
defaultValue: '1024*1024',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '1:1 (1024x1024)', value: '1024*1024' },
|
||||
{ label: '16:9 (1280x720)', value: '1280*720' },
|
||||
{ label: '9:16 (720x1280)', value: '720*1280' },
|
||||
{ label: '4:3 (1024x768)', value: '1024*768' },
|
||||
{ label: '3:4 (768x1024)', value: '768*1024' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
415
jeecgboot-vue3/src/views/super/airag/aiposter/AiPoster.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div class="ai-poster-page">
|
||||
<div class="page-header">
|
||||
<span class="title">AI 海报生成</span>
|
||||
<span class="subtitle">输入提示词,快速生成精美海报</span>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- 左侧模板选择 -->
|
||||
<div class="template-panel">
|
||||
<div class="panel-title">模板选择</div>
|
||||
<div class="template-list">
|
||||
<div
|
||||
v-for="item in templates"
|
||||
:key="item.id"
|
||||
class="template-item"
|
||||
:class="{ active: activeTemplateId === item.id }"
|
||||
@click="handleSelectTemplate(item)"
|
||||
>
|
||||
<div class="template-cover">
|
||||
<img :src="item.url" style="width: 100%" />
|
||||
</div>
|
||||
<div class="template-name">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 中间参数配置 -->
|
||||
<div class="config-panel">
|
||||
<div class="panel-title">参数配置</div>
|
||||
<div class="form-container">
|
||||
<BasicForm @register="registerForm" />
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="primary" size="large" block @click="handleGenerate" :loading="loading">
|
||||
<Icon icon="ant-design:thunderbolt-outlined" />
|
||||
立即生成
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧图片生成结果 -->
|
||||
<div class="preview-panel">
|
||||
<div class="panel-title">生成结果</div>
|
||||
<div class="preview-content">
|
||||
<div v-if="!generatedImage && !loading" class="empty-state">
|
||||
<Icon icon="ant-design:picture-outlined" size="64" color="#ccc" />
|
||||
<p>在左侧配置参数并点击生成</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<a-spin size="large" tip="正在绘制海报,请稍候..." />
|
||||
</div>
|
||||
|
||||
<div v-if="generatedImage" class="result-image-wrapper group">
|
||||
<img :src="generatedImage" class="result-image" alt="Generated Poster" />
|
||||
<div class="image-actions">
|
||||
<a-button type="primary" ghost @click="handlePreview">
|
||||
<Icon icon="ant-design:eye-outlined" />
|
||||
预览
|
||||
</a-button>
|
||||
<a-button type="primary" ghost @click="handleDownload">
|
||||
<Icon icon="ant-design:download-outlined" />
|
||||
下载
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageViewer v-if="previewVisible" :imageUrl="generatedImage" @hide="previewVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { formSchema } from './AiPoster.data';
|
||||
import ImageViewer from '../aiapp/chat/components/ImageViewer.vue';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import { useGlobSetting } from '@/hooks/setting';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const loading = ref(false);
|
||||
const generatedImage = ref('');
|
||||
const previewVisible = ref(false);
|
||||
|
||||
const activeTemplateId = ref<number | null>(null);
|
||||
|
||||
const templates = [
|
||||
{
|
||||
id: 1,
|
||||
name: '淡雅政务风',
|
||||
prompt:
|
||||
'淡雅政务风横版海报,主色调浅蓝 + 米白 + 淡灰,扁平化矢量风格,叠加细腻宣纸纹理;画面核心元素:简约政务办公楼轮廓(线条简洁)、金色钢笔、展开的公文册、淡蓝色祥云纹样、橄榄枝装饰;背景是米白渐变 + 浅蓝竖条肌理,点缀细金色边框;文字设计:居中用黑体写‘政务为民・高效规范’,下方配‘用心服务・务实笃行’浅蓝小字;整体氛围淡雅庄重、专业简洁,层次分明,光影柔和,高清细节,竖版 9:16 构图',
|
||||
size: '720*1280',
|
||||
url: 'https://minio.jeecg.com/otatest/simple_1767767784521.png',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '节日海报',
|
||||
prompt:
|
||||
'国潮中国风春节竖版海报,主色调红金 + 暖橙渐变,国潮插画风格,矢量扁平 + 柔和渐变质感,叠加细腻宣纸纹理;画面层次:前景是红色剪纸风梅花、金色福字贴纸、饱满水饺、红色灯笼串,中景是红墙金瓦的传统民居屋檐、飘带式祥云,远景是淡金色烟花绽放 + 暖红色光晕背景;点缀金色铜钱纹、折纸兔子、如意纹样;画面中央偏上用金色书法字体写‘新春大吉’,下方配\'万事如意\'四字楷书;整体氛围喜庆祥和、团圆温馨,层次分明主次清晰,光影柔和不刺眼,高清细节,竖版 9:16 构图',
|
||||
url: 'https://minio.jeecg.com/otatest/image89444392111_1767844276342.png',
|
||||
size: '720*1280',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '科技宣传',
|
||||
prompt:
|
||||
'未来科技感宣传海报,主色调蓝紫渐变 + 银白金属色,冷光霓虹光效,赛博朋克线条质感;画面核心元素:全息投影的地球数据模型、流动的蓝色数据流、发光的电路板纹理、悬浮的芯片与机械齿轮、未来感建筑轮廓;点缀粒子光效、透明全息界面、霓虹光带;文字设计:居中用未来感无衬线字体写‘科技赋能・智启新程’,下方配‘创新驱动・引领未来’小字,字体带轻微发光描边;整体氛围简洁高级、充满未来感,层次分明,光影锐利,高清细节,横版 16:9 构图',
|
||||
size: '720*1280',
|
||||
url: 'https://minio.jeecg.com/otatest/technology_1767765484936.png',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '优雅复古',
|
||||
prompt:
|
||||
'民国风优雅复古竖版海报,主色调米黄 + 豆沙红 + 墨黑,低饱和度胶片质感,叠加老报纸纹理与轻微颗粒感;画面核心元素:穿月白旗袍的女性侧影(盘发配珍珠发簪)、油纸伞、复古留声机、雕花木质窗棂、缠绕珍珠的藤蔓花纹;背景是模糊的老上海石库门建筑轮廓,点缀淡粉色玉兰花、复古字体排版的诗句(‘岁月静好,温婉如初’);文字设计:上方用民国手写体写‘雅致时光’,下方配衬线字体‘复刻民国风雅’,字体带轻微做旧效果;整体氛围温婉知性、静谧典雅,光影柔和(侧光勾勒人物轮廓),层次分明,高清细节,竖版 9:16 构图',
|
||||
size: '720*1280',
|
||||
url: 'https://minio.jeecg.com/otatest/retro_1767765748402.png',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '赛博朋克',
|
||||
prompt:
|
||||
'国潮赛博朋克横版海报,主色调中国红 + 深空黑 + 鎏金霓虹,传统纹样与科技元素碰撞,叠加红金渐变光效 + 竹简纹理;画面核心元素:龙形霓虹光带(龙身缠绕电路板)、红墙金瓦的赛博风古建筑(屋檐挂霓虹灯笼)、穿汉服改良款的赛博人物(配发光发簪 / 机械袖)、全息投影的汉字霓虹灯牌(‘江湖’‘未来’);点缀祥云数据流、金属质感的传统回纹、悬浮的鎏金元宝状机械装置;文字设计:上方用金色书法字体写‘赛博江湖’,下方配‘TECH & TRADITION’英文,字体带红金霓虹发光效果;整体氛围大气炫酷、传统与未来交融,光影强烈且富有冲击力,层次分明,高清细节,横版 16:9 构图',
|
||||
size: '720*1280',
|
||||
url: 'https://minio.jeecg.com/otatest/cyberpunk_1767766076979.png',
|
||||
},
|
||||
];
|
||||
const { domainUrl } = useGlobSetting();
|
||||
const [registerForm, { validate, setFieldsValue }] = useForm({
|
||||
schemas: formSchema,
|
||||
labelWidth: 100,
|
||||
actionColOptions: { span: 24 },
|
||||
showActionButtonGroup: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 选中模板事件
|
||||
* @param template
|
||||
*/
|
||||
function handleSelectTemplate(template: any) {
|
||||
activeTemplateId.value = template.id;
|
||||
setFieldsValue({
|
||||
content: template.prompt,
|
||||
imageUrl: template.url,
|
||||
imageSize: template.size,
|
||||
});
|
||||
createMessage.success(`已应用模板:${template.name}`);
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
try {
|
||||
const values = await validate();
|
||||
console.log('Generating with values:', values);
|
||||
loading.value = true;
|
||||
generatedImage.value = '';
|
||||
|
||||
setTimeout(() => {
|
||||
defHttp
|
||||
.post({ url: '/airag/chat/genAiPoster', params: values, timeout: 5 * 60 * 1000 }, { isTransformResponse: false })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
let reg = /#\s*{\s*domainURL\s*}/g;
|
||||
res.result = res.result.replace(reg, domainUrl + '/sys/common/static');
|
||||
generatedImage.value = res.result;
|
||||
createMessage.success('海报生成成功!');
|
||||
} else {
|
||||
createMessage.warning('海报生成失败!');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreview() {
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片导出
|
||||
*/
|
||||
function handleDownload() {
|
||||
if (!generatedImage.value) {
|
||||
return;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = generatedImage.value;
|
||||
a.download = `ai-poster-${Date.now()}.jpg`;
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.ai-poster-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background-color: #f0f2f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #8f959e;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-panel {
|
||||
width: 200px;
|
||||
min-width: 180px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
|
||||
.template-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
.template-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
height: auto;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8c8c8c;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-size: 14px;
|
||||
color: #1f2329;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
width: 550px;
|
||||
min-width: 350px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.form-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #f7f8fc;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #8f959e;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.result-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
&:hover .image-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
import {defHttp} from '/@/utils/http/axios';
|
||||
import { useMessage } from "/@/hooks/web/useMessage";
|
||||
|
||||
const { createConfirm } = useMessage();
|
||||
|
||||
enum Api {
|
||||
list = '/airag/extData/list',
|
||||
queryById = '/airag/extData/queryById',
|
||||
save='/airag/extData/add',
|
||||
edit='/airag/extData/edit',
|
||||
deleteOne = '/airag/extData/delete',
|
||||
deleteBatch = '/airag/extData/deleteBatch',
|
||||
importExcel = '/airag/extData/importExcel',
|
||||
exportXls = '/airag/extData/exportXls',
|
||||
debugEvaluator = '/airag/extData/evaluator/debug',
|
||||
|
||||
queryTrackById = '/airag/extData/queryTrackById',
|
||||
getTrackList = '/airag/extData/getTrackList',
|
||||
}
|
||||
/**
|
||||
* 导出api
|
||||
* @param params
|
||||
*/
|
||||
export const getExportUrl = Api.exportXls;
|
||||
/**
|
||||
* 导入api
|
||||
*/
|
||||
export const getImportUrl = Api.importExcel;
|
||||
/**
|
||||
* 列表接口
|
||||
* @param params
|
||||
*/
|
||||
export const list = (params) =>
|
||||
defHttp.get({url: Api.list, params});
|
||||
/**
|
||||
* 调用轨迹接口
|
||||
* @param params
|
||||
*/
|
||||
export const getTrackList = (params) =>
|
||||
defHttp.get({url: Api.getTrackList, params});
|
||||
/**
|
||||
* 根据数据集id查询数据
|
||||
* @param params
|
||||
*/
|
||||
export const queryById = (params) =>
|
||||
defHttp.get({url: Api.queryById, params},{isTransformResponse: false});
|
||||
/**
|
||||
* 根据数据集id查询数据
|
||||
* @param params
|
||||
*/
|
||||
export const queryTrackById = (params) =>
|
||||
defHttp.get({url: Api.queryTrackById, params},{isTransformResponse: false});
|
||||
|
||||
/**
|
||||
* 删除单个
|
||||
*/
|
||||
export const deleteOne = (params,handleSuccess) => {
|
||||
return defHttp.delete({url: Api.deleteOne, params}, {joinParamsToUrl: true}).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 批量删除
|
||||
* @param params
|
||||
*/
|
||||
export const batchDelete = (params, handleSuccess) => {
|
||||
createConfirm({
|
||||
iconType: 'warning',
|
||||
title: '确认删除',
|
||||
content: '是否删除选中数据',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
return defHttp.delete({url: Api.deleteBatch, data: params}, {joinParamsToUrl: true}).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 保存或者更新
|
||||
* @param params
|
||||
*/
|
||||
export const saveOrUpdate = (params, isUpdate,showSuccessMsg=true) => {
|
||||
const url = isUpdate ? Api.edit : Api.save;
|
||||
return defHttp.post({url: url, params},{successMessageMode:showSuccessMsg?'success':'none'});
|
||||
}
|
||||
/**
|
||||
* 調試
|
||||
* @param params
|
||||
*/
|
||||
export const debugEvaluator = (params) => {
|
||||
return defHttp.post({url: Api.debugEvaluator, params},{ isTransformResponse: false });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BasicColumn } from '/@/components/Table';
|
||||
import { FormSchema } from '/@/components/Table';
|
||||
//列表数据
|
||||
export const columns: BasicColumn[] = [
|
||||
{
|
||||
title: '名称',
|
||||
align: 'center',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: '描述信息',
|
||||
align: 'center',
|
||||
dataIndex: 'descr',
|
||||
},
|
||||
];
|
||||
//查询数据
|
||||
export const searchFormSchema: FormSchema[] = [
|
||||
{
|
||||
label: '名称',
|
||||
field: 'name',
|
||||
component: 'Input',
|
||||
colProps: {
|
||||
span: 6,
|
||||
},
|
||||
},
|
||||
];
|
||||
//表单数据
|
||||
export const formSchema: FormSchema[] = [
|
||||
{
|
||||
label: '业务类型标识',
|
||||
field: 'bizType',
|
||||
component: 'Input',
|
||||
defaultValue: 'evaluator',
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: '名称',
|
||||
field: 'name',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
label: '描述信息',
|
||||
field: 'descr',
|
||||
component: 'InputTextArea',
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
field: 'id',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 流程表单调用这个方法获取formSchema
|
||||
* @param param
|
||||
*/
|
||||
export function getBpmFormSchema(_formData): FormSchema[] {
|
||||
// 默认和原始表单保持一致 如果流程中配置了权限数据,这里需要单独处理formSchema
|
||||
return formSchema;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<a-card class="ai-prompts-info">
|
||||
<AiragExtDataList ref="processRef" v-if="activeKey==='evaluator'"></AiragExtDataList>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 评估器 evaluator
|
||||
* 评测集 evaluationSet
|
||||
* 实验 experiment
|
||||
*/
|
||||
import { ref, onMounted } from 'vue';
|
||||
import AiragExtDataList from './AiragExtDataList.vue'
|
||||
|
||||
/**
|
||||
* 评估器 evaluator
|
||||
* 评测集 evaluationSet
|
||||
* 实验 experiment
|
||||
*/
|
||||
const activeKey = ref('evaluator');
|
||||
/**
|
||||
* 切换tab
|
||||
*/
|
||||
function handleChangePanel(key) {
|
||||
activeKey.value = key;
|
||||
}
|
||||
|
||||
onMounted(()=>{
|
||||
activeKey.value = 'evaluator';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.ai-prompts-info {
|
||||
margin: 10px;
|
||||
:deep(.ant-card-body) {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
:deep(.ant-tabs-tabpane) {
|
||||
padding-left: 12px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div>
|
||||
<!--引用表格-->
|
||||
<BasicTable @register="registerTable" :rowSelection="rowSelection">
|
||||
<!--插槽:table标题-->
|
||||
<template #tableTitle>
|
||||
<a-button type="primary" @click="handleAdd" preIcon="ant-design:plus-outlined"> 新增</a-button>
|
||||
</template>
|
||||
<!--操作栏-->
|
||||
<template #action="{ record }">
|
||||
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
|
||||
</template>
|
||||
</BasicTable>
|
||||
<!-- 表单区域 -->
|
||||
<AiragExtDataModal @register="registerModal" @success="handleSuccess"></AiragExtDataModal>
|
||||
<!-- 评测集 -->
|
||||
<AiragDataSetModal @register="registerDataSetModal" @success="handleSuccess"></AiragDataSetModal>
|
||||
<!-- 评估器配置 -->
|
||||
<AiEvaluatorSettingModal @register="registerEvaluatorModal" @success="handleSuccess"></AiEvaluatorSettingModal>
|
||||
<!-- 调试配置 -->
|
||||
<AiEvaluatorDebugModal @register="registerDebugModal" @success="handleSuccess"></AiEvaluatorDebugModal>
|
||||
<!-- 调用记录 -->
|
||||
<AiragInvokeRecordsDrawer @register="registerRecordsModal" @success="handleSuccess"></AiragInvokeRecordsDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="prompts-airagExtData" setup>
|
||||
import { BasicTable, TableAction } from '/@/components/Table';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { useDrawer } from '/@/components/Drawer';
|
||||
import { useListPage } from '/@/hooks/system/useListPage';
|
||||
import AiragExtDataModal from './components/AiragExtDataModal.vue';
|
||||
import AiragDataSetModal from './components/AiragDataSetModal.vue';
|
||||
import AiEvaluatorSettingModal from './components/AiEvaluatorSettingModal.vue';
|
||||
import AiEvaluatorDebugModal from './components/AiEvaluatorDebugModal.vue';
|
||||
import AiragInvokeRecordsDrawer from './components/AiragInvokeRecordsDrawer.vue';
|
||||
import { columns, searchFormSchema } from './AiragExtData.data';
|
||||
import { list, deleteOne } from './AiragExtData.api';
|
||||
//注册model
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
//注册评测集model
|
||||
const [registerDataSetModal, { openModal:openDataSetModal }] = useModal();
|
||||
//注册评估器model
|
||||
const [registerEvaluatorModal, { openModal:openEvaluatorModal }] = useModal();
|
||||
//注册评估器model
|
||||
const [registerDebugModal, { openModal:openDebugModal }] = useModal();
|
||||
//注册调用记录model
|
||||
const [registerRecordsModal, { openDrawer:openRecordsDrawer }] = useDrawer();
|
||||
const { tableContext } = useListPage({
|
||||
tableProps: {
|
||||
title: 'airag_ext_data',
|
||||
api: list,
|
||||
columns,
|
||||
canResize: true,
|
||||
formConfig: {
|
||||
schemas: searchFormSchema,
|
||||
autoSubmitOnEnter: true,
|
||||
showAdvancedButton: true,
|
||||
fieldMapToNumber: [],
|
||||
fieldMapToTime: [],
|
||||
},
|
||||
actionColumn: {
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
|
||||
|
||||
/**
|
||||
* 新增事件
|
||||
*/
|
||||
function handleAdd() {
|
||||
openModal(true, {
|
||||
isUpdate: false,
|
||||
showFooter: true,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 编辑事件
|
||||
*/
|
||||
function handleEdit(record: Recordable) {
|
||||
openEvaluatorModal(true, {
|
||||
record,
|
||||
isUpdate: true,
|
||||
showFooter: true,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 详情
|
||||
*/
|
||||
function handleDateSet(record: Recordable) {
|
||||
openDataSetModal(true, {
|
||||
record,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 删除事件
|
||||
*/
|
||||
async function handleDelete(record) {
|
||||
await deleteOne({ id: record.id }, handleSuccess);
|
||||
}
|
||||
/**
|
||||
* 调试事件
|
||||
*/
|
||||
async function handleDebug(record) {
|
||||
openDebugModal(true, {
|
||||
record,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 调试事件
|
||||
*/
|
||||
async function handleRecords(record) {
|
||||
openRecordsDrawer(true, {
|
||||
record,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 成功回调
|
||||
*/
|
||||
function handleSuccess() {
|
||||
(selectedRowKeys.value = []) && reload();
|
||||
}
|
||||
/**
|
||||
* 操作栏
|
||||
*/
|
||||
function getTableAction(record) {
|
||||
return [
|
||||
{
|
||||
label: '配置',
|
||||
onClick: handleEdit.bind(null, record),
|
||||
}
|
||||
];
|
||||
}
|
||||
/**
|
||||
* 下拉操作栏
|
||||
*/
|
||||
function getDropDownAction(record) {
|
||||
return [
|
||||
{
|
||||
label: '评测集',
|
||||
onClick: handleDateSet.bind(null, record),
|
||||
},
|
||||
{
|
||||
label: '调试',
|
||||
onClick: handleDebug.bind(null, record),
|
||||
},
|
||||
{
|
||||
label: '调用记录',
|
||||
onClick: handleRecords.bind(null, record),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
popConfirm: {
|
||||
title: '是否确认删除',
|
||||
confirm: handleDelete.bind(null, record),
|
||||
placement: 'topLeft',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.ant-picker),
|
||||
:deep(.ant-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
import {defHttp} from '/@/utils/http/axios';
|
||||
import { useMessage } from "/@/hooks/web/useMessage";
|
||||
|
||||
const { createConfirm } = useMessage();
|
||||
|
||||
enum Api {
|
||||
list = '/airag/prompts/list',
|
||||
save='/airag/prompts/add',
|
||||
edit='/airag/prompts/edit',
|
||||
deleteOne = '/airag/prompts/delete',
|
||||
deleteBatch = '/airag/prompts/deleteBatch',
|
||||
importExcel = '/airag/prompts/importExcel',
|
||||
exportXls = '/airag/prompts/exportXls',
|
||||
|
||||
promptExperiment = '/airag/prompts/experiment',
|
||||
}
|
||||
/**
|
||||
* 导出api
|
||||
* @param params
|
||||
*/
|
||||
export const getExportUrl = Api.exportXls;
|
||||
/**
|
||||
* 导入api
|
||||
*/
|
||||
export const getImportUrl = Api.importExcel;
|
||||
/**
|
||||
* 列表接口
|
||||
* @param params
|
||||
*/
|
||||
export const list = (params) =>
|
||||
defHttp.get({url: Api.list, params});
|
||||
|
||||
/**
|
||||
* 删除单个
|
||||
*/
|
||||
export const deleteOne = (params,handleSuccess) => {
|
||||
return defHttp.delete({url: Api.deleteOne, params}, {joinParamsToUrl: true}).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 批量删除
|
||||
* @param params
|
||||
*/
|
||||
export const batchDelete = (params, handleSuccess) => {
|
||||
createConfirm({
|
||||
iconType: 'warning',
|
||||
title: '确认删除',
|
||||
content: '是否删除选中数据',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
return defHttp.delete({url: Api.deleteBatch, data: params}, {joinParamsToUrl: true}).then(() => {
|
||||
handleSuccess();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 保存或者更新
|
||||
* @param params
|
||||
*/
|
||||
export const saveOrUpdate = (params, isUpdate) => {
|
||||
let url = isUpdate ? Api.edit : Api.save;
|
||||
return defHttp.post({url: url, params});
|
||||
}
|
||||
/**
|
||||
* 实验
|
||||
* @param params
|
||||
*/
|
||||
export const promptExperiment = (params) => {
|
||||
return defHttp.post({url: Api.promptExperiment, params},{ isTransformResponse: false });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { BasicColumn } from '/@/components/Table';
|
||||
import { FormSchema } from '/@/components/Table';
|
||||
import {duplicateCheckDelay} from "@/views/system/user/user.api";
|
||||
export const DESFORM_NAME_MAX_LENGTH = 40;
|
||||
import {pinyin} from "pinyin-pro";
|
||||
//列表数据
|
||||
export const columns: BasicColumn[] = [
|
||||
{
|
||||
title: '名称',
|
||||
align: 'center',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: '功能描述',
|
||||
align: 'center',
|
||||
dataIndex: 'description',
|
||||
},
|
||||
// {
|
||||
// title: '状态',
|
||||
// align: 'center',
|
||||
// dataIndex: 'status',
|
||||
// },
|
||||
{
|
||||
title: '最近提交人',
|
||||
align: 'center',
|
||||
dataIndex: 'updateBy',
|
||||
},
|
||||
{
|
||||
title: '最近提交时间',
|
||||
align: 'center',
|
||||
dataIndex: 'updateTime',
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
align: 'center',
|
||||
dataIndex: 'createBy',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
align: 'center',
|
||||
dataIndex: 'createTime',
|
||||
}
|
||||
];
|
||||
//查询数据
|
||||
export const searchFormSchema: FormSchema[] = [
|
||||
{
|
||||
label: '名称',
|
||||
field: 'name',
|
||||
component: 'Input',
|
||||
},
|
||||
];
|
||||
// 名称最大长度
|
||||
export const NAME_MAX_LENGTH = 40;
|
||||
// 编码最大长度
|
||||
export const CODE_MAX_LENGTH = 50;
|
||||
//表单数据
|
||||
export const formSchema: FormSchema[] = [
|
||||
{
|
||||
label: '名称',
|
||||
field: 'name',
|
||||
component: 'Input',
|
||||
componentProps: ({ formModel }) => {
|
||||
return {
|
||||
placeholder: '例如:SQL转换',
|
||||
maxlength: DESFORM_NAME_MAX_LENGTH,
|
||||
showCount: true,
|
||||
onChange: (e: ChangeEvent) => {
|
||||
if(formModel.id){
|
||||
return
|
||||
}
|
||||
let code = pinyin(e.target.value, {
|
||||
toneType: 'none',
|
||||
type: 'array',
|
||||
nonZh: 'consecutive',
|
||||
}).join('_');
|
||||
code = code.replace(/[^a-zA-Z0-9_\-]/g, '');
|
||||
formModel.promptKey = code;
|
||||
},
|
||||
};
|
||||
},
|
||||
dynamicRules() {
|
||||
return [
|
||||
{required: true, message: '请输入提示词名称'},
|
||||
{
|
||||
max: NAME_MAX_LENGTH,
|
||||
message: `名称长度不能超过${NAME_MAX_LENGTH}个字符`,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '提示词编码',
|
||||
field: 'promptKey',
|
||||
component: 'Input',
|
||||
dynamicRules({ model }) {
|
||||
return [
|
||||
{ required: true, message: '提示词编码' },
|
||||
{
|
||||
async validator(_, value) {
|
||||
if (value?.length > CODE_MAX_LENGTH) {
|
||||
throw `编码长度不能超过${CODE_MAX_LENGTH}个字符`;
|
||||
}
|
||||
const pattern = /^[a-z|A-Z][a-z|A-Z\d_-]*$/;
|
||||
if (!pattern.test(value)) {
|
||||
throw '编码必须以字母开头,可包含数字、下划线、横杠';
|
||||
} else if (/[A-Z]/.test(value)) {
|
||||
throw '不支持大写字母';
|
||||
} else {
|
||||
const res = await duplicateCheckDelay({
|
||||
tableName: 'airag_prompts',
|
||||
fieldName: 'prompt_key',
|
||||
fieldVal: value,
|
||||
dataId: model.id,
|
||||
}) as any;
|
||||
if (!res.success) {
|
||||
throw '表单编码已存在!';
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '提示词功能描述',
|
||||
field: 'description',
|
||||
component: 'InputTextArea',
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
field: 'id',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 流程表单调用这个方法获取formSchema
|
||||
* @param param
|
||||
*/
|
||||
export function getBpmFormSchema(_formData): FormSchema[] {
|
||||
// 默认和原始表单保持一致 如果流程中配置了权限数据,这里需要单独处理formSchema
|
||||
return formSchema;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div>
|
||||
<!--引用表格-->
|
||||
<BasicTable @register="registerTable" :rowSelection="rowSelection">
|
||||
<!--插槽:table标题-->
|
||||
<template #tableTitle>
|
||||
<a-button type="primary" @click="handleAdd" preIcon="ant-design:plus-outlined">新增</a-button>
|
||||
|
||||
<a-dropdown v-if="selectedRowKeys.length > 0">
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="1" @click="batchHandleDelete">
|
||||
<Icon icon="ant-design:delete-outlined"></Icon>
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button v-auth="'prompts:airag_prompts:deleteBatch'"
|
||||
>批量操作
|
||||
<Icon icon="mdi:chevron-down"></Icon>
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
<!--操作栏-->
|
||||
<template #action="{ record }">
|
||||
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
|
||||
</template>
|
||||
</BasicTable>
|
||||
<!-- 表单区域 -->
|
||||
<AiragPromptsModal @register="registerModal" @success="handleSuccess"></AiragPromptsModal>
|
||||
<!-- 表单区域 -->
|
||||
<AiPromptSettingModal @register="registerSettingModal" @success="handleSuccess"></AiPromptSettingModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="prompts-airagPrompts" setup>
|
||||
import { BasicTable, TableAction } from '/@/components/Table';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { useListPage } from '/@/hooks/system/useListPage';
|
||||
import AiragPromptsModal from './components/AiragPromptsModal.vue';
|
||||
import AiPromptSettingModal from './components/AiPromptSettingModal.vue';
|
||||
import { columns, searchFormSchema } from './AiragPrompts.data';
|
||||
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl } from './AiragPrompts.api';
|
||||
//注册model
|
||||
const [registerModal, { openModal }] = useModal();
|
||||
//注册model
|
||||
const [registerSettingModal, { openModal: openSettingModal }] = useModal();
|
||||
//注册table数据
|
||||
const { tableContext } = useListPage({
|
||||
tableProps: {
|
||||
title: 'airag_prompts',
|
||||
api: list,
|
||||
columns,
|
||||
canResize: true,
|
||||
formConfig: {
|
||||
//labelWidth: 120,
|
||||
schemas: searchFormSchema,
|
||||
autoSubmitOnEnter: true,
|
||||
showAdvancedButton: true,
|
||||
fieldMapToNumber: [],
|
||||
fieldMapToTime: [],
|
||||
},
|
||||
actionColumn: {
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
},
|
||||
},
|
||||
exportConfig: {
|
||||
name: 'airag_prompts',
|
||||
url: getExportUrl,
|
||||
},
|
||||
importConfig: {
|
||||
url: getImportUrl,
|
||||
success: handleSuccess,
|
||||
},
|
||||
});
|
||||
|
||||
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
|
||||
|
||||
/**
|
||||
* 新增事件
|
||||
*/
|
||||
function handleAdd() {
|
||||
openModal(true, {
|
||||
isUpdate: false,
|
||||
showFooter: true,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 编辑事件
|
||||
*/
|
||||
function handleEdit(record: Recordable) {
|
||||
openSettingModal(true, {
|
||||
record,
|
||||
isUpdate: true,
|
||||
showFooter: true,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 详情
|
||||
*/
|
||||
function handleDetail(record: Recordable) {
|
||||
openModal(true, {
|
||||
record,
|
||||
isUpdate: true,
|
||||
showFooter: false,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 删除事件
|
||||
*/
|
||||
async function handleDelete(record) {
|
||||
await deleteOne({ id: record.id }, handleSuccess);
|
||||
}
|
||||
/**
|
||||
* 批量删除事件
|
||||
*/
|
||||
async function batchHandleDelete() {
|
||||
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
|
||||
}
|
||||
/**
|
||||
* 成功回调
|
||||
*/
|
||||
function handleSuccess() {
|
||||
(selectedRowKeys.value = []) && reload();
|
||||
}
|
||||
/**
|
||||
* 操作栏
|
||||
*/
|
||||
function getTableAction(record) {
|
||||
return [
|
||||
{
|
||||
label: '配置',
|
||||
onClick: handleEdit.bind(null, record),
|
||||
},
|
||||
];
|
||||
}
|
||||
/**
|
||||
* 下拉操作栏
|
||||
*/
|
||||
function getDropDownAction(record) {
|
||||
return [
|
||||
{
|
||||
label: '详情',
|
||||
onClick: handleDetail.bind(null, record),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
popConfirm: {
|
||||
title: '是否确认删除',
|
||||
confirm: handleDelete.bind(null, record),
|
||||
placement: 'topLeft',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.ant-picker),
|
||||
:deep(.ant-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,894 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-bind="$attrs"
|
||||
@register="registerModal"
|
||||
:canFullscreen="true"
|
||||
defaultFullscreen
|
||||
destroyOnClose
|
||||
title="评测调试"
|
||||
:showOkBtn="false"
|
||||
:showCancelBtn="false"
|
||||
wrapClassName="evaluator-debug-modal"
|
||||
>
|
||||
<!-- 步骤条 -->
|
||||
<a-steps :current="currentStep" class="steps-container">
|
||||
<a-step title="选择提示词">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:file-search-outlined" />
|
||||
</template>
|
||||
</a-step>
|
||||
<a-step title="调试配置">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:setting-outlined" />
|
||||
</template>
|
||||
</a-step>
|
||||
</a-steps>
|
||||
|
||||
<!-- 第一步:选择提示词和字段映射 -->
|
||||
<div v-show="currentStep === 0" class="step-content step-one">
|
||||
<div class="form-section">
|
||||
<div class="form-item">
|
||||
<label class="form-label">选择提示词:</label>
|
||||
<a-select v-model:value="selectedPromptKey" placeholder="请选择提示词" class="prompt-select" @change="onPromptChange">
|
||||
<a-select-option v-for="prompt in promptOptions" :key="prompt.key" :value="prompt.key">
|
||||
<span class="prompt-option">
|
||||
<Icon icon="ant-design:file-text-outlined" />
|
||||
{{ prompt.name }}
|
||||
</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<div class="mapping-section" v-if="selectedPromptKey">
|
||||
<div class="section-header">
|
||||
<h3>
|
||||
<Icon icon="ant-design:link-outlined" />
|
||||
字段映射关系
|
||||
</h3>
|
||||
</div>
|
||||
<div class="mapping-list">
|
||||
<div class="mapping-item">
|
||||
<div class="field-label">
|
||||
<Icon icon="ant-design:tag-outlined" />
|
||||
用户输入[user_query]
|
||||
</div>
|
||||
<div class="equal-sign">=</div>
|
||||
<a-select allow-clear v-model:value="fieldMappings['user_query']" placeholder="请选择数据集字段" class="field-select">
|
||||
<a-select-option v-for="column in datasetColumns" :key="column.key" :value="column.key">
|
||||
<span class="column-option">
|
||||
<span style="color:blue">评测集</span>
|
||||
<span> [{{ column.title }}]</span>
|
||||
<span style="margin-left: 5px">
|
||||
<a-tooltip :title="column.description"><Icon icon="ant-design:info-circle-outlined" size="14" /></a-tooltip>
|
||||
</span>
|
||||
</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="mapping-item" v-for="(item, index) in promptParams" :key="index">
|
||||
<div class="field-label">
|
||||
<Icon icon="ant-design:tag-outlined" />
|
||||
变量[{{ item.name }}]
|
||||
</div>
|
||||
<div class="equal-sign">=</div>
|
||||
<a-select allow-clear v-model:value="fieldMappings[item.name]" placeholder="请选择数据集字段" class="field-select">
|
||||
<a-select-option v-for="column in datasetColumns" :key="column.key" :value="column.key">
|
||||
<span class="column-option">
|
||||
<span style="color:blue">评测集</span>
|
||||
<span> [{{ column.title }}]</span>
|
||||
<span style="margin-left: 5px">
|
||||
<a-tooltip :title="column.description"><Icon icon="ant-design:info-circle-outlined" size="14" /></a-tooltip>
|
||||
</span>
|
||||
</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<a-button @click="closeModal" class="footer-btn cancel-btn">
|
||||
<Icon icon="ant-design:close-circle-outlined" />
|
||||
取消
|
||||
</a-button>
|
||||
<a-button type="primary" @click="nextStep" :disabled="!selectedPromptKey || !fieldMappings['user_query']" class="footer-btn next-btn">
|
||||
<Icon icon="ant-design:right-circle-outlined" />
|
||||
下一步
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:调试配置和结果显示 -->
|
||||
<div v-show="currentStep === 1" class="step-content step-two">
|
||||
<div class="form-section">
|
||||
<div class="mapping-section">
|
||||
<div class="section-header">
|
||||
<h3>
|
||||
<Icon icon="ant-design:link-outlined" />
|
||||
字段映射
|
||||
</h3>
|
||||
</div>
|
||||
<div class="mapping-list">
|
||||
<div class="mapping-item" v-for="(item, index) in evaluatorFields" :key="index">
|
||||
<div class="field-label">
|
||||
<Icon icon="ant-design:tag-outlined" />
|
||||
评估器 [{{ item.field }}]
|
||||
</div>
|
||||
<div class="equal-sign">=</div>
|
||||
<a-select allow-clear v-model:value="fieldMappings[item.field]" placeholder="请选择数据集字段" class="field-select">
|
||||
<a-select-option v-for="column in evaluatorColumns" :key="column.name" :value="column.name">
|
||||
<span class="column-option">
|
||||
<!-- <a-divider style="margin:4px 0;" v-if="column.label" />-->
|
||||
<span :style="{color:column.label ? 'green' : 'blue'}"> [{{ column.label || '评测集' }}] </span>
|
||||
<span>{{ column.name }}</span>
|
||||
<span style="margin-left: 5px">
|
||||
<a-tooltip :title="column.description">
|
||||
<Icon icon="ant-design:info-circle-outlined" size="14"/>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</span>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debug-result" v-if="debugResult.length > 0 || confirmLoading">
|
||||
<div class="section-header">
|
||||
<h3>
|
||||
<Icon icon="ant-design:bar-chart-outlined" />
|
||||
调试结果
|
||||
</h3>
|
||||
</div>
|
||||
<div class="debug-result-container">
|
||||
<!-- 条件渲染,当有调试结果时显示表格 -->
|
||||
<div class="result-table-container" v-if="debugResult.length > 0">
|
||||
<!-- 添加外部容器和标题 -->
|
||||
<div class="table-header">
|
||||
<div class="table-title" v-if="!confirmLoading">调试结果</div>
|
||||
<div class="table-title" v-if="confirmLoading"><span>实验初始化中,请稍后点击<a href="javascript:void(0)" @click="handleReload">刷新</a></span></div>
|
||||
<div class="table-subtitle">实际输出与参考输出对比</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格区域 -->
|
||||
<a-table
|
||||
:columns="resultColumns"
|
||||
:dataSource="debugResult"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
class="result-table"
|
||||
:rowClassName="setRowClassName"
|
||||
>
|
||||
<!-- 自定义单元格渲染 -->
|
||||
<template #bodyCell="{ column, record, text }">
|
||||
<!-- 状态列特殊样式 -->
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<span :class="['status-badge', getStatusClass(record)]">
|
||||
{{ text }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 无数据时显示加载或提示 -->
|
||||
<div v-else class="no-data-container">
|
||||
<div class="loading-text" v-if="confirmLoading">
|
||||
<loading-outlined style="font-size: 24px; margin-right: 8px" @click="handleReload" />
|
||||
<span>实验初始化中,请稍后点击<a href="javascript:void(0)" @click="handleReload">刷新</a></span>
|
||||
</div>
|
||||
<div class="empty-text" v-else> 暂无调试结果 </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<a-button @click="prevStep" class="footer-btn prev-btn">
|
||||
<Icon icon="ant-design:left-circle-outlined" />
|
||||
上一步
|
||||
</a-button>
|
||||
<a-button v-if="debugResult.length == 0" :disabled="canConfirmDebug" :loading="confirmLoading" type="primary" @click="confirmDebug" class="footer-btn confirm-btn">
|
||||
<Icon v-if="!confirmLoading" icon="ant-design:play-circle-outlined" />
|
||||
{{ confirmLoading ? '正在调试...' : '确认调试配置' }}
|
||||
</a-button>
|
||||
<a-button v-if="debugResult.length > 0" @click="closeModal" class="footer-btn close-btn">
|
||||
<Icon icon="ant-design:check-circle-outlined" />
|
||||
完成
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive,computed } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import Icon from '/@/components/Icon/src/Icon.vue';
|
||||
import { list, promptExperiment } from '../AiragPrompts.api';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
import { queryTrackById } from '@/views/super/airag/aiprompts/AiragExtData.api';
|
||||
import { LoadingOutlined } from '@ant-design/icons-vue';
|
||||
// Emits声明
|
||||
const emit = defineEmits(['register', 'success']);
|
||||
// 确认调试配置加载状态
|
||||
const confirmLoading = ref(false);
|
||||
// 提示信息
|
||||
const { createMessage } = useMessage();
|
||||
//核心数据
|
||||
const record = ref<any>({});
|
||||
// 步骤控制
|
||||
const currentStep = ref(0);
|
||||
// 选择调试的提示词
|
||||
const selectedPromptKey = ref<string>('');
|
||||
const selectedPrompt = ref<any>({});
|
||||
// 评估器字段配置
|
||||
const evaluatorFields = ref<any>([]);
|
||||
// 评估器下拉列配置
|
||||
const evaluatorColumns = ref<any>([]);
|
||||
//字段映射
|
||||
const fieldMappings = reactive<Record<string, string>>({});
|
||||
//页码配置
|
||||
const page = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
// 提示词选项数据
|
||||
const promptOptions = ref<any[]>([]);
|
||||
// 数据集列
|
||||
const datasetColumns = ref<any[]>([]);
|
||||
// 提示词参数
|
||||
const promptParams = ref<any[]>([]);
|
||||
// 调试结果
|
||||
const debugResult = ref<any[]>([]);
|
||||
// 结果表格列
|
||||
const resultColumns = ref<any[]>([
|
||||
{ title: '问题', dataIndex: 'userQuery', key: 'userQuery',fixed: 'left' },
|
||||
{ title: '提示词输出答案', dataIndex: 'promptAnswer', key: 'promptAnswer',fixed: 'left' },
|
||||
{ title: '评分', dataIndex: 'answerScore', key: 'answerScore',fixed: 'left' },
|
||||
]);
|
||||
|
||||
// 确认调试配置
|
||||
const canConfirmDebug = computed(() => {
|
||||
let canConfirm = true;
|
||||
evaluatorFields.value.forEach((field) => {
|
||||
if(!fieldMappings[field.name]){
|
||||
canConfirm = false;
|
||||
}
|
||||
});
|
||||
return canConfirm;
|
||||
});
|
||||
// 表单赋值
|
||||
const [registerModal, { closeModal }] = useModalInner(async (data) => {
|
||||
//调试数据
|
||||
record.value = data.record;
|
||||
//重置数据
|
||||
resetForm();
|
||||
//查询提示词
|
||||
await getPromptList();
|
||||
//查询测评列配置
|
||||
getDatasetColumns();
|
||||
//查询评估器字段配置
|
||||
getEvaluatorFields();
|
||||
console.log('evaluatorColumns.value', evaluatorColumns.value);
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取数据集列字段
|
||||
*/
|
||||
function getDatasetColumns() {
|
||||
let datasetValue = record.value?.datasetValue;
|
||||
if (datasetValue) {
|
||||
let columns = JSON.parse(datasetValue).columns;
|
||||
columns.forEach((item) => {
|
||||
if(item.name !== 'action') {
|
||||
datasetColumns.value.push({ ...item, key: item.name, title: item.name });
|
||||
evaluatorColumns.value.push({ ...item });
|
||||
if(!resultColumns.value.some(a=> a.dataIndex === item.name)){
|
||||
resultColumns.value.push({ title: item.name, dataIndex: item.name, key: item.name });
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
createMessage.warning('未配置评测集信息!');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取数据集列字段
|
||||
*/
|
||||
function getEvaluatorFields() {
|
||||
let dataValue = record.value?.dataValue;
|
||||
if (dataValue) {
|
||||
evaluatorFields.value = dataValue.match(/{{\s*([^}\s]+)\s*}}/g).map((match) => ({ field: match.replace(/{{\s*|\s*}}/g, '') }));
|
||||
} else {
|
||||
createMessage.warning('未配置评测集信息!');
|
||||
}
|
||||
//列配置
|
||||
evaluatorColumns.value.push({ name: 'actual_output', label: '评测对象', description: '实际输出', dateType: 'String' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示词
|
||||
*/
|
||||
async function getPromptList() {
|
||||
const res = await list({ ...page });
|
||||
if (res?.records) {
|
||||
res?.records.forEach((item) => {
|
||||
promptOptions.value.push({ name: item.name, key: item.promptKey, value: item.promptKey, ...item });
|
||||
});
|
||||
}
|
||||
}
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
currentStep.value = 0;
|
||||
selectedPromptKey.value = '';
|
||||
promptOptions.value = [];
|
||||
datasetColumns.value = [];
|
||||
promptParams.value = [];
|
||||
evaluatorColumns.value = [];
|
||||
Object.keys(fieldMappings).forEach((key) => {
|
||||
delete fieldMappings[key];
|
||||
});
|
||||
debugResult.value = [];
|
||||
}
|
||||
|
||||
// 提示词改变事件
|
||||
function onPromptChange(value: string) {
|
||||
//选中的提示词信息
|
||||
selectedPrompt.value = promptOptions.value.find((item) => item.value == value);
|
||||
// 清空之前的映射
|
||||
Object.keys(fieldMappings).forEach((key) => {
|
||||
delete fieldMappings[key];
|
||||
});
|
||||
let modelParam = selectedPrompt.value?.modelParam;
|
||||
if(modelParam){
|
||||
modelParam = typeof modelParam === 'string' ? JSON.parse(modelParam) : modelParam;
|
||||
if(modelParam?.promptVariables && modelParam?.promptVariables.length > 0){
|
||||
modelParam.promptVariables.forEach((item) => {
|
||||
promptParams.value.push({ name: item.name, label: item.name, description: item.description, dateType: 'String' });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下一步
|
||||
function nextStep() {
|
||||
if (selectedPromptKey.value) {
|
||||
currentStep.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 上一步
|
||||
function prevStep() {
|
||||
confirmLoading.value = false;
|
||||
debugResult.value = [];
|
||||
currentStep.value = 0;
|
||||
}
|
||||
|
||||
// 确认调试配置
|
||||
async function confirmDebug() {
|
||||
// 模拟调试过程
|
||||
let params = {
|
||||
mappings: fieldMappings,
|
||||
promptKey: selectedPromptKey.value,
|
||||
extDataId: record.value.id,
|
||||
};
|
||||
console.log('开始调试...', params);
|
||||
confirmLoading.value = true;
|
||||
let res = await promptExperiment(params);
|
||||
console.log('结束调试res...', res);
|
||||
emit('success');
|
||||
}
|
||||
|
||||
// 刷新评测集
|
||||
async function handleReload() {
|
||||
debugResult.value = []
|
||||
let res = await queryTrackById({ id: record.value.id });
|
||||
console.log('刷新评测集res...', res);
|
||||
if (!res.success) {
|
||||
createMessage.error(res.message);
|
||||
return;
|
||||
} else {
|
||||
if (res.result && res.result.length > 0) {
|
||||
console.log('刷新评测集res.result...', res.result);
|
||||
confirmLoading.value = false;
|
||||
// 1. 先找出最大version值
|
||||
const maxVersion = Math.max(...res.result.map(item => item.version));
|
||||
|
||||
// 2. 过滤出所有具有最大version的项
|
||||
const maxVersionItems = res.result.filter(item => item.version === maxVersion);
|
||||
|
||||
console.log('刷新评测集maxVersionItems...', maxVersionItems);
|
||||
if (maxVersionItems.length > 0) {
|
||||
maxVersionItems.forEach((item) => {
|
||||
debugResult.value.push(JSON.parse(item.dataValue));
|
||||
});
|
||||
}
|
||||
}else{
|
||||
createMessage.warning('数据处理中,请稍后刷新!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 状态样式类
|
||||
const getStatusClass = (record) => {
|
||||
if (record.status === '成功') return 'status-success';
|
||||
if (record.status === '失败') return 'status-failed';
|
||||
if (record.status === '警告') return 'status-warning';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 行样式
|
||||
const setRowClassName = (record, index) => {
|
||||
return index % 2 === 0 ? 'even-row' : 'odd-row';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@primary-color: #1890ff;
|
||||
@success-color: #52c41a;
|
||||
@warning-color: #faad14;
|
||||
@error-color: #ff4d4f;
|
||||
@border-color: #e8e8e8;
|
||||
@background-color: #f5f7fa;
|
||||
@text-color: #262626;
|
||||
@text-secondary: #8c8c8c;
|
||||
|
||||
.steps-container {
|
||||
margin: 15px 0;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(90deg, #f0f8ff, #e6f7ff);
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 200px);
|
||||
:deep(.ant-steps-item-icon) {
|
||||
background: white;
|
||||
border-color: @primary-color;
|
||||
width: 35px;
|
||||
.ant-steps-icon {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-steps-item-finish) {
|
||||
.ant-steps-item-icon {
|
||||
background: @primary-color;
|
||||
width: 35px;
|
||||
.ant-steps-icon {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-steps-item-title {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-steps-item-title) {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
|
||||
|
||||
&.step-one {
|
||||
.prompt-select {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&.step-two {
|
||||
.mapping-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
background-color: @background-color;
|
||||
border-radius: 6px;
|
||||
border: 1px solid @border-color;
|
||||
|
||||
.field-key {
|
||||
font-weight: 500;
|
||||
color: @text-color;
|
||||
margin-right: 12px;
|
||||
min-width: 80px;
|
||||
|
||||
&::after {
|
||||
content: ':';
|
||||
}
|
||||
}
|
||||
|
||||
.field-value {
|
||||
color: @text-secondary;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: @text-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
svg {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
.prompt-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-section {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background-color: @background-color;
|
||||
border-radius: 8px;
|
||||
border: 1px solid @border-color;
|
||||
|
||||
.mapping-list {
|
||||
.mapping-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border: 1px solid @border-color;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-color: @primary-color;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: @text-color;
|
||||
min-width: 140px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.equal-sign {
|
||||
font-weight: bold;
|
||||
color: @text-secondary;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.field-select {
|
||||
flex: 1;
|
||||
|
||||
.column-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.debug-result {
|
||||
margin-top: 36px;
|
||||
padding: 20px;
|
||||
background-color: @background-color;
|
||||
border-radius: 8px;
|
||||
border: 1px solid @border-color;
|
||||
|
||||
.result-table-container {
|
||||
margin-top: 16px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid @border-color;
|
||||
|
||||
:deep(.result-table) {
|
||||
.ant-table-thead > tr > th {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover {
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
padding-top: 24px;
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid @border-color;
|
||||
|
||||
.footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.cancel-btn {
|
||||
&:hover {
|
||||
color: @error-color;
|
||||
border-color: @error-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.next-btn,
|
||||
&.confirm-btn {
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&.prev-btn {
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.close-btn {
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(82, 196, 26, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.step-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mapping-section {
|
||||
padding: 16px;
|
||||
|
||||
.mapping-list {
|
||||
.mapping-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.field-label {
|
||||
margin-bottom: 10px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.equal-sign {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.field-select {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-two .mapping-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
flex-direction: column;
|
||||
|
||||
.footer-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.debug-result-container {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-bottom: none;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.table-subtitle {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.result-table {
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr.even-row) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr.odd-row) {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover) {
|
||||
background-color: #e6f7ff !important;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
background-color: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background-color: #fffbe6;
|
||||
color: #faad14;
|
||||
border: 1px solid #ffe58f;
|
||||
}
|
||||
|
||||
.latency-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.latency-low {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.latency-medium {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.latency-high {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.input-cell,
|
||||
.output-cell {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.output-cell.reference .cell-value {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.output-cell.actual .cell-value {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 24px;
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.footer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footer-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.footer-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.no-data-container {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,917 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal
|
||||
wrapClassName="ai-prompt-edit-modal"
|
||||
destroyOnClose
|
||||
@register="registerModal"
|
||||
:canFullscreen="false"
|
||||
defaultFullscreen
|
||||
width="800px"
|
||||
:footer="null"
|
||||
@visible-change="visibleChange"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<template #title>
|
||||
<div style="display: flex; width: 100%; justify-content: space-between; align-items: center">
|
||||
<div style="display: flex">
|
||||
<img :src="getImage()" class="header-img" />
|
||||
<div class="header-name">{{ formState.name }}</div>
|
||||
<a-tooltip title="提示词评估器">
|
||||
<Icon icon="ant-design:edit-outlined" style="margin-left: 4px; cursor: pointer" color="#354052" size="20" @click="handleEdit"></Icon>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<!-- <div>-->
|
||||
<!-- 应用编排-->
|
||||
<!-- <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>-->
|
||||
<!-- </div>-->
|
||||
<div style="display: flex">
|
||||
<a-button @click="handleOk" style="margin-right: 30px" type="primary">保存</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div style="height: 100%; width: 100%">
|
||||
<a-row :span="24">
|
||||
<a-col :span="showTest?12:24">
|
||||
<div class="orchestration">提示词评估器</div>
|
||||
</a-col>
|
||||
<a-col :span="12" v-if="showTest">
|
||||
<div class="view">构造测试数据</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :span="24">
|
||||
<a-col :span="showTest?12:24" class="setting-left">
|
||||
<a-form class="antd-modal-form" ref="formRef" :model="formState" :rules="validatorRules">
|
||||
<a-row>
|
||||
<a-col :span="24">
|
||||
<div class="prologue-chunk">
|
||||
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol" v-bind="validateInfos.modelId">
|
||||
<template #label>
|
||||
<div style="display: flex; justify-content: space-between; width: 100%; margin-right: 2px">
|
||||
<div class="item-title">AI模型</div>
|
||||
<div @click="handleParamSettingClick('model')" class="knowledge-txt">
|
||||
<Icon icon="ant-design:setting-outlined" size="13" style="margin-right: 2px"></Icon>参数配置
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<JDictSelectTag
|
||||
v-model:value="formState.modelId"
|
||||
placeholder="请选择AI模型"
|
||||
dict-code="airag_model where model_type = 'LLM' and activate_flag = 1,name,id"
|
||||
style="width: 100%"
|
||||
@change="handleModelIdChange"
|
||||
></JDictSelectTag>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-col>
|
||||
<!-- 提示词 -->
|
||||
<a-col :span="24" class="mt-10">
|
||||
<div class="prompt-back">
|
||||
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol" v-bind="validateInfos.dataValue" style="margin-bottom: 0">
|
||||
<template #label>
|
||||
<div class="prompt-title-padding item-title space-between">
|
||||
<span>评估器</span>
|
||||
<a-button size="middle" ghost>
|
||||
<span style="align-items: center; display: flex" @click="generatedPrompt">
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
<span style="margin-left: 4px">生成</span>
|
||||
</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<a-textarea :rows="16" v-model:value="formState.dataValue" placeholder="请输入评估提示" />
|
||||
<!-- 评估器内容格式 -->
|
||||
<div class="variable-container">
|
||||
<div class="variable-container-header">
|
||||
<Icon icon="ant-design:file-text-outlined" class="output-format-icon" />
|
||||
<span class="variable-format-title">评估器内容变量要求(点击变量插入到评估器内容)</span>
|
||||
</div>
|
||||
<div class="variable-container-content">
|
||||
<div class="variable-tag-wrapper">
|
||||
<a-tooltip title="评估的输入内容变量(必填)">
|
||||
<a-tag color="blue" class="variable-tag required-tag" @click="handleTagClick('input')">input</a-tag>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="评估的输出内容变量(必填)">
|
||||
<a-tag color="blue" class="variable-tag required-tag" @click="handleTagClick('output')">output</a-tag>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="评估的参考内容变量">
|
||||
<a-tag color="default" class="variable-tag optional-tag" @click="handleTagClick('reference')">reference</a-tag>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 输出格式 -->
|
||||
<div class="output-format-card">
|
||||
<div class="output-format-header">
|
||||
<Icon icon="ant-design:file-text-outlined" class="output-format-icon" />
|
||||
<span class="output-format-title">输出格式要求</span>
|
||||
</div>
|
||||
<div class="output-format-content">
|
||||
<div class="output-item">
|
||||
<!-- <div class="output-item-label">-->
|
||||
<!-- <span class="output-item-title">得分:</span>-->
|
||||
<!-- </div>-->
|
||||
<div class="output-item-desc">
|
||||
得分:最终的得分,必须输出,必须输出一个数字,表示满足Prompt中评分标准的程度。得分范围从
|
||||
<span class="score-range">0.0</span> 到 <span class="score-range">1.0</span>,<span class="score-range">1.0</span>
|
||||
表示完全满足评分标准,<span class="score-range">0.0</span> 表示完全不满足评分标准。
|
||||
</div>
|
||||
</div>
|
||||
<div class="output-item">
|
||||
<div class="output-item-label">
|
||||
<!-- <span class="output-item-bullet">•</span>-->
|
||||
<!-- <span class="output-item-title">原因:</span>-->
|
||||
</div>
|
||||
<div class="output-item-desc">
|
||||
原因:对得分的可读解释。最后,必须用一句话结束理由,该句话为:因此,应该给出的分数是你的评分。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-button v-if="showTest" class="mt-10 ml" style="float: right" @click="showTest = false">取消</a-button>
|
||||
<!-- <a-button class="mt-10" style="float: right" @click="showTest = true" type="primary">调试</a-button>-->
|
||||
</a-col>
|
||||
<a-col :span="12" class="setting-right" v-if="showTest">
|
||||
<EvaluatorDebug ref="debugRef" :content="formState.dataValue" @run="debugRun"></EvaluatorDebug>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</BasicModal>
|
||||
|
||||
<!-- Ai配置弹窗 -->
|
||||
<AiAppParamsSettingModal @register="registerParamsSettingModal" @ok="handleParamsSettingOk"></AiAppParamsSettingModal>
|
||||
<!-- Ai生成器 -->
|
||||
<AiAppGeneratedPromptModal @register="registerAiAppPromptModal" @ok="handleAiAppPromptOk"></AiAppGeneratedPromptModal>
|
||||
<!-- Ai评估器弹窗 -->
|
||||
<AiragExtDataModal @register="registerEvaluatorModal" @success="handleSuccess"></AiragExtDataModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModal, useModalInner } from '@/components/Modal';
|
||||
import {Form, message} from 'ant-design-vue';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import JDictSelectTag from '@/components/Form/src/jeecg/components/JDictSelectTag.vue';
|
||||
import AiAppParamsSettingModal from '@/views/super/airag/aiapp/components/AiAppParamsSettingModal.vue';
|
||||
import AiAppGeneratedPromptModal from '@/views/super/airag/aiapp/components/AiAppGeneratedPromptModal.vue';
|
||||
import AiragExtDataModal from './AiragExtDataModal.vue';
|
||||
import EvaluatorDebug from './EvaluatorDebug.vue';
|
||||
import defaultImg from '@/views/super/airag/aiapp/img/ailogo.png';
|
||||
import { getFileAccessHttpUrl, randomString } from '@/utils/common/compUtils';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {debugEvaluator, saveOrUpdate} from '@/views/super/airag/aiprompts/AiragExtData.api';
|
||||
|
||||
//保存或修改
|
||||
const isUpdate = ref<boolean>(false);
|
||||
//uuid
|
||||
const uuid = ref(randomString(16));
|
||||
//showTest 显示调试器
|
||||
const showTest = ref(true);
|
||||
//debugRef 调试器引用
|
||||
const debugRef = ref(null);
|
||||
//form表单数据
|
||||
const formState = reactive<any>({
|
||||
id: '',
|
||||
name: '',
|
||||
dataValue: '',
|
||||
descr: '',
|
||||
modelId: '',
|
||||
metadata: '',
|
||||
});
|
||||
//表单验证
|
||||
const validatorRules = ref<any>({
|
||||
dataValue: [{ required: true, message: '请输入提示词!' }],
|
||||
modelId: [{ required: true, message: '请选择AI模型!' }],
|
||||
});
|
||||
//注册form
|
||||
const useForm = Form.useForm;
|
||||
const { resetFields, validate, validateInfos } = useForm(formState, validatorRules, { immediate: false });
|
||||
const labelCol = ref<any>({ span: 24 });
|
||||
const wrapperCol = ref<any>({ span: 24 });
|
||||
//参数配置
|
||||
const metadata = ref<any>({});
|
||||
// emit
|
||||
const emit = defineEmits(['success', 'register']);
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
clearFormState();
|
||||
resetFields();
|
||||
if (isUpdate.value) {
|
||||
setFormState(data.record);
|
||||
}
|
||||
setModalProps({ bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
|
||||
//注册modal
|
||||
const [registerParamsSettingModal, { openModal: paramsSettingOpen }] = useModal();
|
||||
const [registerAiAppPromptModal, { openModal: aiAppPromptModalOpen }] = useModal();
|
||||
const [registerEvaluatorModal, { openModal: evaluatorModalOpen }] = useModal();
|
||||
|
||||
//编辑
|
||||
function handleEdit() {
|
||||
evaluatorModalOpen(true, { isUpdate: true, showFooter: true, record: {
|
||||
id: formState.id,
|
||||
name: formState.name,
|
||||
descr: formState.descr,
|
||||
} });
|
||||
}
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
try {
|
||||
let values = await validate();
|
||||
metadata.value.modelId = values.modelId;
|
||||
values.metadata = JSON.stringify(cloneDeep(metadata.value));
|
||||
values = Object.assign(formState, values);
|
||||
//提交表单
|
||||
await saveOrUpdate(values, isUpdate.value);
|
||||
setModalProps({ confirmLoading: true });
|
||||
//关闭弹窗
|
||||
closeModal();
|
||||
//刷新列表
|
||||
emit('success');
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签点击事件
|
||||
* @param type
|
||||
*/
|
||||
function handleTagClick(type) {
|
||||
let label = type=='input'?'## 输入参数':type=='output'?'## 输出参数':'## 参考参数';
|
||||
if(!formState.dataValue){
|
||||
formState.dataValue = `${label}:{{${type}}}`;
|
||||
}else{
|
||||
formState.dataValue += `\r\n\r\n${label}:{{${type}}}`;
|
||||
// 获取textarea元素并滚动到底部
|
||||
setTimeout(() => {
|
||||
const textarea = document.querySelector('textarea.ant-input') as HTMLTextAreaElement;
|
||||
if (textarea) {
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 关闭弹窗触发列表刷新
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function visibleChange(value) {
|
||||
if (!value) {
|
||||
emit('success');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 成功回调
|
||||
*/
|
||||
function handleSuccess(data) {
|
||||
setFormState(data);
|
||||
emit('success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数配置点击事件
|
||||
* @param value
|
||||
*/
|
||||
function handleParamSettingClick(value) {
|
||||
paramsSettingOpen(true, { type: value, metadata: metadata.value });
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数配置确定回调事件
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleParamsSettingOk(value) {
|
||||
Object.assign(metadata.value, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图标
|
||||
*/
|
||||
function getImage() {
|
||||
return formState.icon ? getFileAccessHttpUrl(formState.icon) : defaultImg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除参数
|
||||
*/
|
||||
function clearFormState() {
|
||||
formState.id = '';
|
||||
formState.name = '';
|
||||
formState.dataValue = '';
|
||||
formState.descr = '';
|
||||
formState.modelId = '';
|
||||
formState.metadata = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置form属性
|
||||
* @param data
|
||||
*/
|
||||
function setFormState(data: any) {
|
||||
//赋值
|
||||
Object.assign(formState, data);
|
||||
|
||||
// 如果已有metadata,查询模型信息并更新到metadata中
|
||||
if (data?.metadata) {
|
||||
metadata.value = data.metadata ? JSON.parse(data.metadata) : {};
|
||||
formState.modelId = data.metadata ? JSON.parse(data.metadata).modelId || '' : '';
|
||||
}
|
||||
}
|
||||
|
||||
//============= begin 提示词 ================================
|
||||
/**
|
||||
* 生成提示词
|
||||
*/
|
||||
function generatedPrompt() {
|
||||
aiAppPromptModalOpen(true, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提示词回调
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleAiAppPromptOk(value) {
|
||||
formState.dataValue = value;
|
||||
}
|
||||
//============= end 提示词 ================================
|
||||
|
||||
/**
|
||||
* 调试运行
|
||||
*/
|
||||
async function debugRun(variables: any[]) {
|
||||
//提示词
|
||||
let sysMessage = formState.dataValue;
|
||||
let userMessage = "输入的内容:";
|
||||
//替换变量
|
||||
variables.forEach((item) => {
|
||||
userMessage += `${item.name}:${item.value}`;
|
||||
});
|
||||
//定义返回结果
|
||||
sysMessage += '定义返回格式:\n\n 得分:最终的得分,必须输出,必须输出一个数字,表示满足Prompt中评分标准的程度。得分范围从 0.0 到 1.0,1.0 表示完全满足评分标准,0.0 表示完全不满足评分标准。\n' +
|
||||
'原因:(对得分的可读解释)。最后,必须用一句话结束理由,该句话为:因此,应该给出的分数是(你前面得出的评分)。';
|
||||
console.log('userMessage', userMessage);
|
||||
if(!formState.modelId) {
|
||||
message.warning("请选择AI模型");
|
||||
return;
|
||||
}
|
||||
// 调用调试器运行
|
||||
let loading = debugRef.value?.loading;
|
||||
if (loading) return;
|
||||
debugRef.value.loading = true;
|
||||
let params = { prompts: sysMessage,content:userMessage,modelId:formState.modelId,modelParam:JSON.stringify(formState.metadata) }
|
||||
let res = await debugEvaluator(params).catch(() => debugRef.value.loading = false);
|
||||
debugRef.value.loading = false;
|
||||
if(res.success){
|
||||
debugRef.value.result = res.result
|
||||
}else{
|
||||
message.error(res.message);
|
||||
}
|
||||
console.log("debugEvaluator",res)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 模型ID变化处理
|
||||
* 查询模型信息并更新到metadata中,供chat组件使用
|
||||
*/
|
||||
async function handleModelIdChange(modelId: string) {
|
||||
if (!modelId) {
|
||||
// 如果清空模型,清除模型信息
|
||||
if (metadata.value.modelInfo) {
|
||||
delete metadata.value.modelInfo;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await defHttp.get(
|
||||
{
|
||||
url: '/airag/airagModel/queryById',
|
||||
params: { id: modelId },
|
||||
},
|
||||
{ isTransformResponse: false }
|
||||
);
|
||||
if (res.success && res.result) {
|
||||
const model = res.result;
|
||||
// 将模型信息添加到metadata中
|
||||
if (!metadata.value) {
|
||||
metadata.value = {};
|
||||
}
|
||||
metadata.value['modelInfo'] = {
|
||||
provider: model.provider || '',
|
||||
modelType: model.modelType || '',
|
||||
modelName: model.modelName || '',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取模型信息失败', e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.orchestration,
|
||||
.view {
|
||||
color: #0a3069;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
.type-title {
|
||||
color: #1d2025;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.type-desc {
|
||||
color: #8f959e;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.setting-left {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
height: (100vh - 15px);
|
||||
}
|
||||
|
||||
.setting-right {
|
||||
overflow-y: auto;
|
||||
height: (100vh - 15px);
|
||||
border-left: 1px solid #dee0e3;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item .ant-form-item-label > label) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.knowledge-img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.flow-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #354052;
|
||||
width: calc(100% - 20px);
|
||||
overflow: hidden;
|
||||
align-content: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: grid;
|
||||
}
|
||||
.knowledge-name {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
margin-bottom: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.knowledge-icon {
|
||||
display: none !important;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.knowledge-card:hover {
|
||||
.knowledge-icon {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
.header-img {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.header-name {
|
||||
color: #354052;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
margin-left: 10px;
|
||||
align-content: center;
|
||||
}
|
||||
.prompt-back {
|
||||
background-color: #eef4ff;
|
||||
border-radius: 12px;
|
||||
padding: 2px;
|
||||
border: 1px solid #77b2f8;
|
||||
box-sizing: border-box;
|
||||
margin-left: 5px;
|
||||
textarea {
|
||||
min-height: 250px;
|
||||
max-height: 400px;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
.prompt-title-padding {
|
||||
margin-left: 14px;
|
||||
height: 50px;
|
||||
align-content: center;
|
||||
}
|
||||
.prologue-chunk {
|
||||
background-color: #f2f4f7;
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px 2px 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.prologue-chunk-edit {
|
||||
background-color: #f2f4f7;
|
||||
border-radius: 12px;
|
||||
padding: 2px 0 2px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.mt-10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
:deep(.ant-form-item-label) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-required) {
|
||||
margin-left: 4px !important;
|
||||
}
|
||||
.knowledge-txt {
|
||||
color: #354052;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.item-title {
|
||||
color: #111928;
|
||||
font-weight: 400;
|
||||
}
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
:deep(.vditor) {
|
||||
border: none;
|
||||
}
|
||||
:deep(.vditor-sv) {
|
||||
font-size: 14px;
|
||||
}
|
||||
:deep(.vditor-sv:focus) {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.space-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
button {
|
||||
padding: 0 6px;
|
||||
height: 25px;
|
||||
color: #155aef !important;
|
||||
margin-right: 10px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
.ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.quick-command {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
justify-content: space-between;
|
||||
background-color: #ffffff;
|
||||
padding: 4px 8px 4px;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
align-self: center;
|
||||
border-radius: 8px;
|
||||
height: 40px;
|
||||
.quick-command-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.quick-command:hover {
|
||||
background-color: #eff0f8;
|
||||
.quick-command-icon {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.data-empty-text {
|
||||
color: #757c8f;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.mcp-warning-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #fa8c16;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.flow-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
:deep(.ant-card .ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
.text-status {
|
||||
font-size: 12px;
|
||||
color: #676f83;
|
||||
}
|
||||
.tag-text {
|
||||
display: flow;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
color: #3a3f4f;
|
||||
}
|
||||
.tag-input {
|
||||
align-self: center;
|
||||
color: #737c97;
|
||||
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%;
|
||||
}
|
||||
.text-desc {
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
font-size: 12px;
|
||||
color: #676f83;
|
||||
}
|
||||
|
||||
/* 输出格式卡片样式 */
|
||||
.output-format-card {
|
||||
background: #f8f9ff;
|
||||
border: 1px solid #e1e5ff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.output-format-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #e8ecff;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.output-format-icon {
|
||||
color: #155aef;
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.output-format-title {
|
||||
font-weight: 600;
|
||||
color: #155aef;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.output-format-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.output-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.output-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.output-item-bullet {
|
||||
color: #155aef;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.output-item-title {
|
||||
font-weight: 600;
|
||||
color: #354052;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.output-item-desc {
|
||||
color: #5a6376;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.score-range {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
background: #fff5f5;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.output-format-card {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
.output-format-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
|
||||
.output-format-icon {
|
||||
font-size: 16px;
|
||||
color: #1890ff;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.output-format-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.output-format-content {
|
||||
.output-item {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #1890ff;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.output-item-desc {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
line-height: 1.6;
|
||||
|
||||
.score-range {
|
||||
color: #ff7a45;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 122, 69, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.variable-container {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
.variable-container-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
|
||||
.output-format-icon {
|
||||
font-size: 16px;
|
||||
color: #1890ff;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.variable-format-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-container-content {
|
||||
.variable-tag-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.variable-tag {
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
margin: 0;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.required-tag {
|
||||
background-color: #e6f7ff;
|
||||
border: 1px solid #91d5ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.optional-tag {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style lang="less">
|
||||
.ai-prompt-edit-modal {
|
||||
.ant-modal .ant-modal-header {
|
||||
padding: 13px 32px !important;
|
||||
}
|
||||
.jeecg-basic-modal-close > span {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,955 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal
|
||||
wrapClassName="ai-prompt-edit-modal"
|
||||
destroyOnClose
|
||||
@register="registerModal"
|
||||
:canFullscreen="false"
|
||||
defaultFullscreen
|
||||
width="800px"
|
||||
:footer="null"
|
||||
@visible-change="visibleChange"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<template #title>
|
||||
<div style="display: flex; width: 100%; justify-content: space-between; align-items: center">
|
||||
<div style="display: flex">
|
||||
<img :src="getImage()" class="header-img" />
|
||||
<div class="header-name">{{ formState.name }}</div>
|
||||
<a-tooltip title="编辑">
|
||||
<Icon icon="ant-design:edit-outlined" style="margin-left: 4px; cursor: pointer" color="#354052" size="20" @click="handleEdit"></Icon>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div>
|
||||
提示词编排
|
||||
<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>
|
||||
</div>
|
||||
<div style="display: flex">
|
||||
<a-button @click="handleOk" style="margin-right: 30px" type="primary">保存</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div style="height: 100%; width: 100%">
|
||||
<a-row :span="24">
|
||||
<a-col :span="10">
|
||||
<div class="orchestration">提示词</div>
|
||||
</a-col>
|
||||
<a-col :span="14">
|
||||
<div class="view">预览</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :span="24">
|
||||
<a-col :span="10" class="setting-left">
|
||||
<a-form class="antd-modal-form" ref="formRef" :model="formState" :rules="validatorRules">
|
||||
<a-row>
|
||||
<a-col :span="24">
|
||||
<div class="prompt-back">
|
||||
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol" v-bind="validateInfos.content" style="margin-bottom: 0">
|
||||
<template #label>
|
||||
<div class="prompt-title-padding item-title space-between">
|
||||
<span>提示词</span>
|
||||
<a-button size="middle" ghost>
|
||||
<span style="align-items: center; display: flex" @click="generatedPrompt">
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
<span style="margin-left: 4px">生成</span>
|
||||
</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<a-textarea :rows="8" v-model:value="formState.content" placeholder="请输入提示词" @change="handleContentChange" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24" class="mt-10">
|
||||
<div class="prologue-chunk">
|
||||
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol" v-bind="validateInfos.modelId">
|
||||
<template #label>
|
||||
<div style="display: flex; justify-content: space-between; width: 100%; margin-right: 2px">
|
||||
<div class="item-title">AI模型</div>
|
||||
<div @click="handleParamSettingClick('model')" class="knowledge-txt">
|
||||
<Icon icon="ant-design:setting-outlined" size="13" style="margin-right: 2px"></Icon>参数配置
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<JDictSelectTag
|
||||
v-model:value="formState.modelId"
|
||||
placeholder="请选择AI模型"
|
||||
dict-code="airag_model where model_type = 'LLM' and activate_flag = 1,name,id"
|
||||
style="width: 100%"
|
||||
@change="handleModelIdChange"
|
||||
></JDictSelectTag>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="24" class="mt-10" v-if="metadata.promptVariables && metadata.promptVariables.length > 0">
|
||||
<div class="prologue-chunk">
|
||||
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol">
|
||||
<template #label>
|
||||
<div style="display: flex; justify-content: space-between; width: 100%; margin-right: 2px">
|
||||
<div class="item-title">Prompt 变量</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 变量列表容器 -->
|
||||
<div class="prompt-variable-container">
|
||||
<!-- 无变量时的空状态 -->
|
||||
<div v-if="!metadata.promptVariables || metadata.promptVariables.length === 0" class="empty-variables">
|
||||
<Icon icon="ant-design:inbox-outlined" style="font-size: 32px; color: #d9d9d9" />
|
||||
<p>暂无变量</p>
|
||||
<span class="empty-hint">请在提示词中使用 {{ 变量名 }} 格式添加变量</span>
|
||||
</div>
|
||||
|
||||
<!-- 变量列表 -->
|
||||
<div v-else class="prompt-variables-list">
|
||||
<div
|
||||
class="prompt-variable-item"
|
||||
v-for="(item, index) in metadata.promptVariables"
|
||||
:key="index"
|
||||
:class="{ 'variable-focused': focusedVariable === index }"
|
||||
>
|
||||
<!-- 变量名标签 -->
|
||||
<div class="variable-tag">
|
||||
<Icon icon="ant-design:tag-outlined" />
|
||||
<span class="variable-name">{{ item.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 变量值输入框 -->
|
||||
<div class="variable-input-wrapper">
|
||||
<!-- 附件输入框 -->
|
||||
<JImageUpload :maxCount="1" v-if="item.type === 'FILE'" v-model:value="item.value"></JImageUpload>
|
||||
<!-- 文本输入框 -->
|
||||
<a-input
|
||||
v-else
|
||||
v-model:value="item.value"
|
||||
placeholder="请输入变量值"
|
||||
@focus="focusedVariable = index"
|
||||
@blur="focusedVariable = null"
|
||||
class="variable-input"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon v-if="item.value" icon="ant-design:check-circle-outlined" style="color: #52c41a" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<!-- 类型选择框 -->
|
||||
<div class="variable-input-wrapper">
|
||||
<a-select
|
||||
v-model:value="item.type"
|
||||
placeholder="请选择类型"
|
||||
:options="[
|
||||
{ value: 'TEXT', label: '文本' },
|
||||
{ value: 'FILE', label: '附件' }
|
||||
]"
|
||||
class="variable-input"
|
||||
>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<!-- 变量操作按钮 -->
|
||||
<div class="variable-actions">
|
||||
<a-tooltip title="清空值">
|
||||
<a-button type="text" size="small" @click="item.value = ''" class="action-btn">
|
||||
<Icon icon="ant-design:clear-outlined" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-col>
|
||||
<a-col :span="14" class="setting-right">
|
||||
<chat :uuid="uuid" :formState="chatData" url="/airag/app/debug"></chat>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</BasicModal>
|
||||
|
||||
<!-- Ai配置弹窗 -->
|
||||
<AiAppParamsSettingModal @register="registerParamsSettingModal" @ok="handleParamsSettingOk"></AiAppParamsSettingModal>
|
||||
<!-- Ai生成器 -->
|
||||
<AiAppGeneratedPromptModal @register="registerAiAppPromptModal" @ok="handleAiAppPromptOk"></AiAppGeneratedPromptModal>
|
||||
<!-- Ai提示词弹窗 -->
|
||||
<AiragPromptsModal @register="registerPromptModal" @success="handleSuccess"></AiragPromptsModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModal, useModalInner } from '@/components/Modal';
|
||||
import { Form } from 'ant-design-vue';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import JDictSelectTag from '@/components/Form/src/jeecg/components/JDictSelectTag.vue';
|
||||
import AiAppParamsSettingModal from '@/views/super/airag/aiapp/components/AiAppParamsSettingModal.vue';
|
||||
import AiAppGeneratedPromptModal from '@/views/super/airag/aiapp/components/AiAppGeneratedPromptModal.vue';
|
||||
import AiragPromptsModal from './AiragPromptsModal.vue';
|
||||
import chat from '@/views/super/airag/aiapp/chat/chat.vue';
|
||||
import defaultImg from '@/views/super/airag/aiapp/img/ailogo.png';
|
||||
import { getFileAccessHttpUrl, randomString } from '@/utils/common/compUtils';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { saveOrUpdate } from '@/views/super/airag/aiprompts/AiragPrompts.api';
|
||||
import { JImageUpload } from "@/components/Form";
|
||||
|
||||
//保存或修改
|
||||
const isUpdate = ref<boolean>(false);
|
||||
//uuid
|
||||
const uuid = ref(randomString(16));
|
||||
//form表单数据
|
||||
const formState = reactive<any>({
|
||||
id: '',
|
||||
name: '',
|
||||
promptKey: '',
|
||||
content: '',
|
||||
description: '',
|
||||
modelId: '',
|
||||
modelParam: '',
|
||||
});
|
||||
//表单验证
|
||||
const validatorRules = ref<any>({
|
||||
content: [{ required: true, message: '请输入提示词!' }],
|
||||
modelId: [{ required: true, message: '请选择AI模型!' }],
|
||||
});
|
||||
//注册form
|
||||
const useForm = Form.useForm;
|
||||
const { resetFields, validate, validateInfos } = useForm(formState, validatorRules, { immediate: false });
|
||||
const labelCol = ref<any>({ span: 24 });
|
||||
const wrapperCol = ref<any>({ span: 24 });
|
||||
//参数配置
|
||||
const metadata = ref<any>({});
|
||||
//定义 emit 事件
|
||||
const emit = defineEmits(['success', 'register']);
|
||||
// 添加聚焦变量索引
|
||||
const focusedVariable = ref<number | null>(null);
|
||||
// 提示词
|
||||
const prompt = ref<any>();
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
clearFormState();
|
||||
resetFields();
|
||||
if (isUpdate.value) {
|
||||
setFormState(data.record);
|
||||
}
|
||||
setModalProps({ bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
|
||||
//注册modal
|
||||
const [registerParamsSettingModal, { openModal: paramsSettingOpen }] = useModal();
|
||||
const [registerAiAppPromptModal, { openModal: aiAppPromptModalOpen }] = useModal();
|
||||
const [registerPromptModal, { openModal: promptModalOpen }] = useModal();
|
||||
|
||||
const chatData = computed(() => {
|
||||
return {
|
||||
id: formState.id,
|
||||
name: formState.name,
|
||||
prompt: prompt.value,
|
||||
modelId: formState.modelId,
|
||||
metadata: metadata.value?.modelInfo ? JSON.stringify(metadata.value.modelInfo) : '',
|
||||
};
|
||||
});
|
||||
|
||||
watch(
|
||||
() => metadata.value,
|
||||
() => {
|
||||
changePrompt();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
/**
|
||||
* 改变提示词
|
||||
*/
|
||||
function changePrompt() {
|
||||
prompt.value = formState.content;
|
||||
if (metadata.value && metadata.value['promptVariables']) {
|
||||
metadata.value['promptVariables'].forEach((variable) => {
|
||||
if (variable.type === 'FILE') {
|
||||
variable.value = getFileAccessHttpUrl(variable.value);
|
||||
}
|
||||
prompt.value = prompt.value.replace(new RegExp(`{{${variable.name}}}`, 'g'), variable.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提示词内容改变事件
|
||||
*/
|
||||
function handleContentChange() {
|
||||
if (formState.content) {
|
||||
console.log("formState.content",formState.content)
|
||||
let variables = extractVariables(formState.content);
|
||||
console.log("variables",variables)
|
||||
if(variables.length > 0){
|
||||
const promptVariables = metadata.value['promptVariables'];
|
||||
metadata.value['promptVariables'] = variables.map((variable) => ({
|
||||
name: variable,
|
||||
value: promptVariables.find((item) => item.name === variable)?.value || '',
|
||||
type: promptVariables.find((item) => item.name === variable)?.type || 'TEXT'
|
||||
}));
|
||||
}else{
|
||||
metadata.value['promptVariables'] = '';
|
||||
}
|
||||
} else {
|
||||
metadata.value['promptVariables'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从提示词中提取变量名
|
||||
* @param str
|
||||
*/
|
||||
function extractVariables(str) {
|
||||
// 匹配 {{ 和 }} 之间的内容,非贪婪匹配
|
||||
const regex = /\{\{(.*?)\}\}/g;
|
||||
const matches = new Set(); // 使用 Set 自动去重
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(str)) !== null) {
|
||||
matches.add(match[1].trim()); // 去除可能的首尾空格并添加到 Set
|
||||
}
|
||||
|
||||
return Array.from(matches);;
|
||||
}
|
||||
|
||||
//编辑
|
||||
function handleEdit() {
|
||||
promptModalOpen(true, {
|
||||
isUpdate: true,
|
||||
showFooter: true,
|
||||
record: {
|
||||
id: formState.id,
|
||||
name: formState.name,
|
||||
promptKey: formState.promptKey,
|
||||
description: formState.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
try {
|
||||
let values = await validate();
|
||||
values.modelParam = JSON.stringify(cloneDeep(metadata.value));
|
||||
values = Object.assign(formState, values);
|
||||
//提交表单
|
||||
await saveOrUpdate(values, isUpdate.value);
|
||||
setModalProps({ confirmLoading: true });
|
||||
//关闭弹窗
|
||||
closeModal();
|
||||
//刷新列表
|
||||
emit('success');
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗触发列表刷新
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function visibleChange(value) {
|
||||
if (!value) {
|
||||
emit('success');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 成功回调
|
||||
*/
|
||||
function handleSuccess(data) {
|
||||
setFormState(data);
|
||||
emit('success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数配置点击事件
|
||||
* @param value
|
||||
*/
|
||||
function handleParamSettingClick(value) {
|
||||
paramsSettingOpen(true, { type: value, metadata: metadata.value });
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数配置确定回调事件
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleParamsSettingOk(value) {
|
||||
Object.assign(metadata.value, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图标
|
||||
*/
|
||||
function getImage() {
|
||||
return formState.icon ? getFileAccessHttpUrl(formState.icon) : defaultImg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除参数
|
||||
*/
|
||||
function clearFormState() {
|
||||
formState.id = '';
|
||||
formState.name = '';
|
||||
formState.content = '';
|
||||
formState.description = '';
|
||||
formState.modelId = '';
|
||||
formState.modelParam = '';
|
||||
metadata.value = {};
|
||||
prompt.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置form属性
|
||||
* @param data
|
||||
*/
|
||||
function setFormState(data: any) {
|
||||
//赋值
|
||||
Object.assign(formState, data);
|
||||
|
||||
// 如果已有modelId,查询模型信息并更新到metadata中
|
||||
if (data.modelId) {
|
||||
metadata.value = data.modelParam ? JSON.parse(data.modelParam) : {};
|
||||
}
|
||||
console.log('设置form属性formState', formState);
|
||||
}
|
||||
|
||||
//============= begin 提示词 ================================
|
||||
/**
|
||||
* 生成提示词
|
||||
*/
|
||||
function generatedPrompt() {
|
||||
aiAppPromptModalOpen(true, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提示词回调
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleAiAppPromptOk(value) {
|
||||
console.log('handleAiAppPromptOk value', value);
|
||||
formState.content = value;
|
||||
}
|
||||
//============= end 提示词 ================================
|
||||
|
||||
/**
|
||||
* 模型ID变化处理
|
||||
* 查询模型信息并更新到metadata中,供chat组件使用
|
||||
*/
|
||||
async function handleModelIdChange(modelId: string) {
|
||||
if (!modelId) {
|
||||
// 如果清空模型,清除模型信息
|
||||
if (metadata.value.modelInfo) {
|
||||
delete metadata.value.modelInfo;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await defHttp.get(
|
||||
{
|
||||
url: '/airag/airagModel/queryById',
|
||||
params: { id: modelId },
|
||||
},
|
||||
{ isTransformResponse: false }
|
||||
);
|
||||
if (res.success && res.result) {
|
||||
const model = res.result;
|
||||
// 将模型信息添加到metadata中
|
||||
if (!metadata.value) {
|
||||
metadata.value = {};
|
||||
}
|
||||
metadata.value['modelInfo'] = {
|
||||
provider: model.provider || '',
|
||||
modelType: model.modelType || '',
|
||||
modelName: model.modelName || '',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取模型信息失败', e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.orchestration,
|
||||
.view {
|
||||
color: #0a3069;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
width: 100%;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.type-title {
|
||||
color: #1d2025;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.type-desc {
|
||||
color: #8f959e;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.setting-left {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
height: (100vh - 15px);
|
||||
}
|
||||
|
||||
.setting-right {
|
||||
overflow-y: auto;
|
||||
height: (100vh - 15px);
|
||||
border-left: 1px solid #dee0e3;
|
||||
}
|
||||
|
||||
:deep(.ant-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item .ant-form-item-label > label) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.knowledge-img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.flow-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #354052;
|
||||
width: calc(100% - 20px);
|
||||
overflow: hidden;
|
||||
align-content: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: grid;
|
||||
}
|
||||
.knowledge-name {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.knowledge-card {
|
||||
margin-bottom: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.knowledge-icon {
|
||||
display: none !important;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.knowledge-card:hover {
|
||||
.knowledge-icon {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
.header-img {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.header-name {
|
||||
color: #354052;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
margin-left: 10px;
|
||||
align-content: center;
|
||||
}
|
||||
.prompt-back {
|
||||
background-color: #eef4ff;
|
||||
border-radius: 12px;
|
||||
padding: 2px;
|
||||
border: 1px solid #77b2f8;
|
||||
box-sizing: border-box;
|
||||
margin-left: 5px;
|
||||
textarea {
|
||||
min-height: 250px;
|
||||
max-height: 400px;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
.prompt-title-padding {
|
||||
margin-left: 14px;
|
||||
height: 50px;
|
||||
align-content: center;
|
||||
}
|
||||
.prologue-chunk {
|
||||
background-color: #f2f4f7;
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px 2px 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.prologue-chunk-edit {
|
||||
background-color: #f2f4f7;
|
||||
border-radius: 12px;
|
||||
padding: 2px 0 2px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.mt-10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
:deep(.ant-form-item-label) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-required) {
|
||||
margin-left: 4px !important;
|
||||
}
|
||||
.knowledge-txt {
|
||||
color: #354052;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.item-title {
|
||||
color: #111928;
|
||||
font-weight: 400;
|
||||
}
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
:deep(.vditor) {
|
||||
border: none;
|
||||
}
|
||||
:deep(.vditor-sv) {
|
||||
font-size: 14px;
|
||||
}
|
||||
:deep(.vditor-sv:focus) {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.space-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
button {
|
||||
padding: 0 6px;
|
||||
height: 25px;
|
||||
color: #155aef !important;
|
||||
margin-right: 10px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
.ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.quick-command {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
justify-content: space-between;
|
||||
background-color: #ffffff;
|
||||
padding: 4px 8px 4px;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
align-self: center;
|
||||
border-radius: 8px;
|
||||
height: 40px;
|
||||
.quick-command-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.quick-command:hover {
|
||||
background-color: #eff0f8;
|
||||
.quick-command-icon {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.data-empty-text {
|
||||
color: #757c8f;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.mcp-warning-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #fa8c16;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.flow-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
:deep(.ant-card .ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
.text-status {
|
||||
font-size: 12px;
|
||||
color: #676f83;
|
||||
}
|
||||
.tag-text {
|
||||
display: flow;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
color: #3a3f4f;
|
||||
}
|
||||
.tag-input {
|
||||
align-self: center;
|
||||
color: #737c97;
|
||||
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%;
|
||||
}
|
||||
.text-desc {
|
||||
width: 100%;
|
||||
font-weight: 400;
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
font-size: 12px;
|
||||
color: #676f83;
|
||||
}
|
||||
// 变量信息样式
|
||||
.variable-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #676f83;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 变量容器样式
|
||||
.prompt-variable-container {
|
||||
margin-top: 12px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
// 空状态样式
|
||||
.empty-variables {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
|
||||
p {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 16px;
|
||||
color: #676f83;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
// 变量列表样式
|
||||
.prompt-variables-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 变量项样式
|
||||
.prompt-variable-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 悬停效果
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-color: #1890ff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
// 聚焦效果
|
||||
&.variable-focused {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
// 左侧装饰条
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #1890ff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
&.variable-focused::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 变量标签样式
|
||||
.variable-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 120px;
|
||||
padding: 6px 12px;
|
||||
background: #f0f8ff;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #b7e3ff;
|
||||
margin-right: 16px;
|
||||
|
||||
.variable-name {
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 变量输入框包装器
|
||||
.variable-input-wrapper {
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
|
||||
.variable-input {
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 变量操作按钮
|
||||
.variable-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
.action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: #676f83;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f8ff;
|
||||
color: #1890ff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 变量项悬停时显示操作按钮
|
||||
.prompt-variable-item:hover .variable-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.prompt-variable-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
|
||||
.variable-tag {
|
||||
margin-right: 0;
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.variable-input-wrapper {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.variable-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style lang="less">
|
||||
.ai-prompt-edit-modal {
|
||||
.ant-modal .ant-modal-header {
|
||||
padding: 13px 32px !important;
|
||||
}
|
||||
.jeecg-basic-modal-close > span {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-bind="$attrs"
|
||||
@register="registerModal"
|
||||
:canFullscreen="true"
|
||||
destroyOnClose
|
||||
title="列配置"
|
||||
okText="保存"
|
||||
:width="1000"
|
||||
@ok="handleSubmit"
|
||||
>
|
||||
<div class="dynamic-column-config">
|
||||
<a-card size="small">
|
||||
<!-- 列配置列表 -->
|
||||
<div class="column-list">
|
||||
<div v-for="(element, index) in columns" :key="element.id" class="column-item">
|
||||
<!-- 操作区域 - 放在右上角 -->
|
||||
<div class="action-area">
|
||||
<a-button type="text" @click="handleCopyColumn(element)" title="复制">
|
||||
<copy-outlined />
|
||||
</a-button>
|
||||
<a-button type="text" danger @click="handleRemoveColumn(index)" :disabled="columns.length < 2" title="删除">
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 列配置表单 -->
|
||||
<div class="column-form">
|
||||
<div class="form-row">
|
||||
<div class="form-item">
|
||||
<label class="form-label">名称</label>
|
||||
<a-input
|
||||
v-model:value="element.name"
|
||||
placeholder="请输入列名称"
|
||||
:class="{ 'has-error': !element.name }"
|
||||
@blur="validateColumn(element)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">数据类型</label>
|
||||
<a-select v-model:value="element.dataType" placeholder="请选择数据类型" style="width: 100%">
|
||||
<a-select-option value="String">字符串</a-select-option>
|
||||
<a-select-option value="FILE">附件</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">必填</label>
|
||||
<a-radio-group v-model:value="element.required">
|
||||
<a-radio :value="true">是</a-radio>
|
||||
<a-radio :value="false">否</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-item full-width">
|
||||
<label class="form-label">描述</label>
|
||||
<a-textarea v-model:value="element.description" placeholder="请输入列描述" :rows="2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-button type="dashed" style="width: 100%" @click="handleAddColumn"> <PlusOutlined />添加列 </a-button>
|
||||
</div>
|
||||
<!-- 底部操作按钮 -->
|
||||
<!-- <div class="footer-actions">-->
|
||||
<!-- <a-button type="primary" @click="handleSubmit" :loading="loading"> 创建 </a-button>-->
|
||||
<!-- <a-button @click="handleReset" style="margin-left: 8px"> 重置 </a-button>-->
|
||||
<!-- </div>-->
|
||||
</a-card>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons-vue';
|
||||
import { saveOrUpdate } from '../AiragExtData.api';
|
||||
import {JImageUpload} from "@/components/Form";
|
||||
// Emits声明
|
||||
const emit = defineEmits(['register', 'success']);
|
||||
// 列配置接口
|
||||
interface ColumnConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dataType: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false);
|
||||
const columns = ref<ColumnConfig[]>([
|
||||
{
|
||||
id: generateId(),
|
||||
name: '',
|
||||
description: '',
|
||||
dataType: 'String',
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
const defaultColumns = [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'input',
|
||||
description: '作为输入投递给评测对象',
|
||||
dataType: 'String',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'reference_output',
|
||||
description: '预期理想输出,可作为评估时的参考标准',
|
||||
dataType: 'String',
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
//数据ID
|
||||
const dataId = ref('');
|
||||
const datasetValue = ref<any>({});
|
||||
// 生成唯一ID
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
//表单赋值
|
||||
const [registerModal, { closeModal }] = useModalInner(async (data) => {
|
||||
console.log('data:', data);
|
||||
dataId.value = data.id;
|
||||
if(data.datasetValue){
|
||||
datasetValue.value = data.datasetValue
|
||||
if(data.datasetValue?.columns && data.datasetValue.columns.length){
|
||||
columns.value = data.datasetValue.columns;
|
||||
}else{
|
||||
columns.value = defaultColumns;
|
||||
}
|
||||
}
|
||||
});
|
||||
// 添加列
|
||||
const handleAddColumn = () => {
|
||||
columns.value.push({
|
||||
id: generateId(),
|
||||
name: '',
|
||||
description: '',
|
||||
dataType: 'String',
|
||||
required: false,
|
||||
});
|
||||
};
|
||||
|
||||
// 删除列
|
||||
const handleRemoveColumn = (index: number) => {
|
||||
if (columns.value.length > 1) {
|
||||
columns.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
// 复制列
|
||||
const handleCopyColumn = (element) => {
|
||||
columns.value.push({
|
||||
...element,
|
||||
id: generateId(),
|
||||
});
|
||||
};
|
||||
|
||||
// 验证列配置
|
||||
const validateColumn = (column: ColumnConfig) => {
|
||||
return column.name && column.name.trim() !== '';
|
||||
};
|
||||
|
||||
// 提交配置
|
||||
const handleSubmit = async () => {
|
||||
// 验证数据
|
||||
const invalidColumns = columns.value.filter((col) => !validateColumn(col));
|
||||
|
||||
if (invalidColumns.length > 0) {
|
||||
message.error('请填写所有必填项(名称)');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
datasetValue.value.columns = columns.value
|
||||
// 构造提交数据
|
||||
const submitData = {
|
||||
datasetValue: JSON.stringify( datasetValue.value),
|
||||
id: dataId.value,
|
||||
};
|
||||
|
||||
console.log('提交数据:', submitData);
|
||||
|
||||
await saveOrUpdate(submitData, true,false);
|
||||
|
||||
message.success('配置创建成功!');
|
||||
// 关闭弹窗并触发成功事件
|
||||
closeModal();
|
||||
emit('success');
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 重置配置
|
||||
const handleReset = () => {
|
||||
columns.value = [...defaultColumns];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dynamic-column-config {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
max-height: 70vh; /* 设置最大高度为视口高度的70% */
|
||||
overflow-y: auto; /* 超出时显示垂直滚动条 */
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-extra) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-list {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.column-item {
|
||||
position: relative; /* 为绝对定位的操作区域提供参考 */
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f6fbff;
|
||||
}
|
||||
|
||||
/* 操作区域样式 */
|
||||
.action-area {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
|
||||
:deep(.ant-btn) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.column-form {
|
||||
flex: 1;
|
||||
width: 100%; /* 确保表单占满宽度 */
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-item {
|
||||
flex: 1;
|
||||
|
||||
&.full-width {
|
||||
flex: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
&::after {
|
||||
content: ':';
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.has-error {
|
||||
:deep(.ant-input) {
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: none; /* 隐藏原来的删除按钮区域 */
|
||||
}
|
||||
|
||||
.fixed-columns {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px dashed #d9d9d9;
|
||||
|
||||
.tip-text {
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-column-item {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-input-group-addon) {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<BasicDrawer v-bind="getProps">
|
||||
<div class="dynamic-column-config">
|
||||
<!-- 列配置列表 -->
|
||||
<div class="column-list">
|
||||
<!-- 移除了 draggable 组件,直接使用 div 渲染列表 -->
|
||||
<div v-for="(element, index) in dataSet" :key="index" class="column-item">
|
||||
<!-- 操作区域 - 放在右上角 -->
|
||||
<div class="action-area">
|
||||
<a-button type="text" danger @click="handleRemoveData(index)" title="删除">
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
<!-- 列配置表单 -->
|
||||
<div class="column-form">
|
||||
<div class="form-row">
|
||||
<div class="form-item" v-for="item in columns" :key="item.name">
|
||||
<label class="form-label">{{ item.name }}</label>
|
||||
<!-- 附件输入框 -->
|
||||
<JImageUpload :maxCount="1" v-if="item.dataType === 'FILE'" v-model:value="element[item.name]"></JImageUpload>
|
||||
<a-input
|
||||
v-else
|
||||
v-model:value="element[item.name]"
|
||||
:placeholder="'请输入' + item.name"
|
||||
:class="{ 'has-error': !element[item.name] }"
|
||||
@blur="validateColumn(element[item.name])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-button v-if="!isUpdate" type="dashed" style="width: 100%" @click="handleAddData"> <PlusOutlined />添加数据 </a-button>
|
||||
</div>
|
||||
</BasicDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerProps } from '/@/components/Drawer';
|
||||
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
|
||||
import { computed, unref, ref } from 'vue';
|
||||
import { useAttrs } from '@/hooks/core/useAttrs';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { saveOrUpdate } from '@/views/super/airag/aiprompts/AiragExtData.api';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {JImageUpload} from "@/components/Form";
|
||||
// Emits声明
|
||||
const emit = defineEmits(['register', 'success']);
|
||||
const attrs = useAttrs();
|
||||
// 消息提示
|
||||
const { createMessage } = useMessage();
|
||||
// 注册抽屉
|
||||
const [registerDrawer, { closeDrawer }] = useDrawerInner(open);
|
||||
// 抽屉最终props
|
||||
const getProps = computed(() => {
|
||||
let drawerProps: Partial<DrawerProps> = {
|
||||
width: 1000,
|
||||
title: '数据配置',
|
||||
showFooter: true,
|
||||
destroyOnClose: true,
|
||||
};
|
||||
let finalProps: Recordable = {
|
||||
...unref(attrs),
|
||||
...drawerProps,
|
||||
okText: '保存',
|
||||
onOk: handleSubmit,
|
||||
onCancel: closeDrawer,
|
||||
onRegister: registerDrawer,
|
||||
};
|
||||
return finalProps;
|
||||
});
|
||||
/** 更新状态 */
|
||||
const isUpdate = ref<boolean>(false);
|
||||
/** dataId */
|
||||
const dataId = ref<any>('');
|
||||
/** 数据列表 */
|
||||
const allData = ref<any>([]);
|
||||
/** 数据列表 */
|
||||
const dataSet = ref<any>([]);
|
||||
// 列配置
|
||||
const columns = ref<any>([]);
|
||||
// 验证数据非空
|
||||
const validateColumn = (value) => {
|
||||
return value && value.trim() !== '';
|
||||
};
|
||||
// 生成唯一ID
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
//添加数据
|
||||
function handleAddData() {
|
||||
dataSet.value.push({
|
||||
id: generateId(),
|
||||
});
|
||||
}
|
||||
//保存数据
|
||||
async function handleSubmit() {
|
||||
// 验证数据
|
||||
const invalidData = dataSet.value.filter((item) => {
|
||||
return columns.value.some((col) => col.required && !validateColumn(item[col.name]));
|
||||
});
|
||||
if (invalidData.length > 0) {
|
||||
createMessage.error('请填写所有必填项(名称)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 构造提交数据
|
||||
let dataSource:any = [];
|
||||
if (isUpdate.value) {
|
||||
dataSource = allData.value.map(item =>
|
||||
item.id === dataSet.value[0].id ? dataSet.value[0] : item
|
||||
);
|
||||
} else {
|
||||
dataSource = allData.value.concat(dataSet.value);
|
||||
}
|
||||
const submitData = {
|
||||
datasetValue: JSON.stringify({
|
||||
columns: columns.value,
|
||||
dataSource: dataSource,
|
||||
}),
|
||||
id: dataId.value,
|
||||
};
|
||||
|
||||
console.log('提交数据:', submitData);
|
||||
|
||||
await saveOrUpdate(submitData, true, false);
|
||||
|
||||
message.success('数据保存成功!');
|
||||
// 关闭弹窗并触发成功事件
|
||||
closeDrawer();
|
||||
emit('success');
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
} finally {
|
||||
//loading.value = false;
|
||||
}
|
||||
}
|
||||
// 删除列
|
||||
const handleRemoveData = (index: number) => {
|
||||
dataSet.value.splice(index, 1);
|
||||
};
|
||||
/** 抽屉开启 */
|
||||
function open(data) {
|
||||
isUpdate.value = data.isUpdate;
|
||||
dataId.value = data?.id || '';
|
||||
allData.value = data?.dataSource || [];
|
||||
columns.value = data?.columns || [];
|
||||
console.log("columns.value",columns.value)
|
||||
if (isUpdate.value) {
|
||||
console.log("data?.record",data?.record)
|
||||
dataSet.value = data?.record?[data?.record] : [];
|
||||
} else {
|
||||
dataSet.value = [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dynamic-column-config {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
max-height: calc(100vh - 100px); /* 设置最大高度为视口高度的70% */
|
||||
overflow-y: auto; /* 超出时显示垂直滚动条 */
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-extra) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-list {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.column-item {
|
||||
position: relative; /* 为绝对定位的操作区域提供参考 */
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f6fbff;
|
||||
}
|
||||
|
||||
/* 操作区域样式 */
|
||||
.action-area {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
|
||||
:deep(.ant-btn) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.column-form {
|
||||
flex: 1;
|
||||
width: 100%; /* 确保表单占满宽度 */
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-item {
|
||||
flex: 1;
|
||||
|
||||
&.full-width {
|
||||
flex: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
&::after {
|
||||
content: ':';
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.has-error {
|
||||
:deep(.ant-input) {
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: none; /* 隐藏原来的删除按钮区域 */
|
||||
}
|
||||
|
||||
.fixed-columns {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px dashed #d9d9d9;
|
||||
|
||||
.tip-text {
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-column-item {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-input-group-addon) {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<BasicModal v-bind="$attrs" :zIndex="999" @register="registerModal" :canFullscreen="true" :footer="null" defaultFullscreen destroyOnClose title="评测集">
|
||||
<div class="modal-content">
|
||||
<div class="top-section">
|
||||
<div class="header-actions">
|
||||
<a-button type="primary" v-if="columns && columns.length > 0" @click="handleAddData">
|
||||
<Icon icon="ant-design:plus-outlined" />
|
||||
新增数据
|
||||
</a-button>
|
||||
<a-button @click="columnConfig" class="config-btn">
|
||||
<Icon icon="ant-design:setting-outlined" />
|
||||
列配置
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:dataSource="dataSource"
|
||||
:pagination="pagination"
|
||||
rowKey="id"
|
||||
class="data-table"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #action="{ record }">
|
||||
<div class="action-buttons">
|
||||
<a-button size="small" @click="handleEditDataset(record)" class="action-btn edit-btn">
|
||||
<Icon icon="ant-design:edit-outlined" />
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleDelete(record)" class="action-btn delete-btn">
|
||||
<Icon icon="ant-design:delete-outlined" />
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
<!--自定义列配置 -->
|
||||
<AiragDataSetColumnModal @register="registerDataSetColumnModal" @success="reload" />
|
||||
<!--自定义数据配置 -->
|
||||
<AiragDataSetDataDrawer @register="registerDataSetDataDrawer" @success="reload" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { BasicModal, useModal, useModalInner } from '/@/components/Modal';
|
||||
import AiragDataSetColumnModal from './AiragDataSetColumnModal.vue';
|
||||
import AiragDataSetDataDrawer from './AiragDataSetDataDrawer.vue';
|
||||
import {queryById, saveOrUpdate} from '../AiragExtData.api';
|
||||
import { useDrawer } from '@/components/Drawer';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {useMessage} from "@/hooks/web/useMessage";
|
||||
// Emits声明
|
||||
const emit = defineEmits(['register', 'success']);
|
||||
const { createMessage } = useMessage();
|
||||
const dataId = ref<string>('');
|
||||
const columns = ref<any>([]);
|
||||
const dataSource = ref<any>([]);
|
||||
const datasetValue = ref<any>({});
|
||||
const pagination = ref({
|
||||
total: 0,
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
//表单赋值
|
||||
const [registerModal] = useModalInner(async (data) => {
|
||||
dataId.value = data.record?.id;
|
||||
initData(data.record)
|
||||
});
|
||||
//注册列配置
|
||||
const [registerDataSetColumnModal, { openModal: openColumnModal }] = useModal();
|
||||
//注册数据抽屉
|
||||
const [registerDataSetDataDrawer, { openDrawer }] = useDrawer();
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
function reload() {
|
||||
queryById({ id: dataId.value }).then((res) => {
|
||||
if (res.success && res.result) {
|
||||
initData(res.result)
|
||||
}
|
||||
});
|
||||
emit('success');
|
||||
}
|
||||
/**
|
||||
* 初始化数据
|
||||
*/
|
||||
function initData(result) {
|
||||
dataSource.value = []
|
||||
columns.value = []
|
||||
datasetValue.value = result?.datasetValue ? JSON.parse(result?.datasetValue) : {};
|
||||
if (datasetValue.value?.columns) {
|
||||
datasetValue.value?.columns.forEach((item) => {
|
||||
columns.value.push({
|
||||
title: item.name,
|
||||
dataIndex: item.name,
|
||||
key: item.name,
|
||||
fixed: 'center',
|
||||
});
|
||||
});
|
||||
}
|
||||
dataSource.value = datasetValue.value?.dataSource || [];
|
||||
//是否已经包含操作列
|
||||
const hasAction = columns.value.some(item=>item.key == 'action');
|
||||
if(!hasAction){
|
||||
columns.value.push({
|
||||
title: "操作",
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 120,
|
||||
slots: { customRender: 'action' },
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 打开列配置
|
||||
*/
|
||||
function columnConfig() {
|
||||
openColumnModal(true, {
|
||||
id: dataId.value,
|
||||
datasetValue: cloneDeep(datasetValue.value),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增数据点击事件
|
||||
*/
|
||||
function handleAddData() {
|
||||
if(!datasetValue.value?.columns || datasetValue.value?.columns.length == 0){
|
||||
createMessage.warning('请先配置列信息');
|
||||
return;
|
||||
}
|
||||
openDrawer(true, {
|
||||
id: dataId.value,
|
||||
dataSource:cloneDeep(datasetValue.value.dataSource),
|
||||
columns:cloneDeep(datasetValue.value.columns),
|
||||
isUpdate:false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑数据点击事件
|
||||
* @param record
|
||||
*/
|
||||
function handleEditDataset(record) {
|
||||
openDrawer(true, {
|
||||
id: dataId.value,
|
||||
dataSource: cloneDeep(dataSource.value),
|
||||
columns: cloneDeep(datasetValue.value?.columns),
|
||||
record: cloneDeep(record),
|
||||
isUpdate:true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除数据点击事件
|
||||
*/
|
||||
function handleDelete(record) {
|
||||
//删除数据
|
||||
dataSource.value = dataSource.value.filter((item) => item.id !== record.id);
|
||||
refreshDataset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单提交事件
|
||||
*/
|
||||
async function refreshDataset() {
|
||||
//提交表单
|
||||
const submitData = {
|
||||
datasetValue: JSON.stringify({
|
||||
columns: datasetValue.value.columns,
|
||||
dataSource: dataSource.value,
|
||||
}),
|
||||
id: dataId.value,
|
||||
};
|
||||
|
||||
console.log('提交数据:', submitData);
|
||||
await saveOrUpdate(submitData, true, false);
|
||||
reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.top-section {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-btn {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover) {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
:deep(.ant-table-pagination) {
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
color: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
border-color: #ff7875;
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间和数字输入框样式 */
|
||||
:deep(.ant-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-calendar-picker) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :maxHeight="500" :width="800" @ok="handleSubmit">
|
||||
<BasicForm @register="registerForm" name="AiragExtDataForm" />
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, unref, reactive } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import { BasicForm, useForm } from '/@/components/Form/index';
|
||||
import { formSchema } from '../AiragExtData.data';
|
||||
import { saveOrUpdate } from '../AiragExtData.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { getDateByPicker } from '/@/utils';
|
||||
const { createMessage } = useMessage();
|
||||
// Emits声明
|
||||
const emit = defineEmits(['register', 'success']);
|
||||
const isUpdate = ref(true);
|
||||
const isDetail = ref(false);
|
||||
//表单配置
|
||||
const [registerForm, { setProps, resetFields, setFieldsValue, validate, scrollToField }] = useForm({
|
||||
labelWidth: 150,
|
||||
schemas: formSchema,
|
||||
showActionButtonGroup: false,
|
||||
baseColProps: { span: 24 },
|
||||
baseRowStyle: { padding: '0 20px' },
|
||||
});
|
||||
//表单赋值
|
||||
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
|
||||
//重置表单
|
||||
await resetFields();
|
||||
setModalProps({ confirmLoading: false, showCancelBtn: !!data?.showFooter, showOkBtn: !!data?.showFooter });
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
isDetail.value = !!data?.showFooter;
|
||||
if (unref(isUpdate)) {
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...data.record,
|
||||
});
|
||||
}
|
||||
// 隐藏底部时禁用整个表单
|
||||
setProps({ disabled: !data?.showFooter });
|
||||
});
|
||||
//设置标题
|
||||
const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(isDetail) ? '详情' : '编辑'));
|
||||
//表单提交事件
|
||||
async function handleSubmit(v) {
|
||||
try {
|
||||
let values = await validate();
|
||||
setModalProps({ confirmLoading: true });
|
||||
//提交表单
|
||||
await saveOrUpdate(values, isUpdate.value);
|
||||
//关闭弹窗
|
||||
closeModal();
|
||||
//刷新列表
|
||||
emit('success');
|
||||
} catch (e) {
|
||||
console.log("e", e)
|
||||
return Promise.reject(e);
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/** 时间和数字输入框样式 */
|
||||
:deep(.ant-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-calendar-picker) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<BasicDrawer v-bind="getProps">
|
||||
<BasicTable @register="registerTable">
|
||||
<!--操作栏-->
|
||||
<template #action="{ record }">
|
||||
<TableAction :actions="getTableAction(record)" /> </template
|
||||
></BasicTable>
|
||||
</BasicDrawer>
|
||||
<AiragTrackDetailModal @register="registerTrackDetailModal" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerProps } from '/@/components/Drawer';
|
||||
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
|
||||
import { computed, unref, ref } from 'vue';
|
||||
import { useAttrs } from '@/hooks/core/useAttrs';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import { getTrackList, deleteOne } from '@/views/super/airag/aiprompts/AiragExtData.api';
|
||||
import {useListPage} from "@/hooks/system/useListPage";
|
||||
import AiragTrackDetailModal from './AiragTrackDetailModal.vue';
|
||||
// Emits声明
|
||||
const attrs = useAttrs();
|
||||
const [registerTrackDetailModal, { openModal }] = useModal();
|
||||
//列配置
|
||||
const trackColumns = ref([
|
||||
{ title: '问题', dataIndex: 'userQuery', key: 'userQuery' },
|
||||
{ title: '提示词答案', dataIndex: 'promptAnswer', key: 'promptAnswer' },
|
||||
{ title: '评估分数', dataIndex: 'answerScore', key: 'answerScore' },
|
||||
{ title: '版本号', dataIndex: 'version', key: 'version' },
|
||||
]);
|
||||
// 注册抽屉
|
||||
const [registerDrawer, { closeDrawer }] = useDrawerInner(open);
|
||||
// 表格定义
|
||||
const { tableContext } = useListPage({
|
||||
designScope: 'agent-config',
|
||||
tableProps: {
|
||||
title: '调用记录',
|
||||
api: getTrackList,
|
||||
columns: trackColumns.value,
|
||||
canResize: false,
|
||||
rowSelection: {
|
||||
columnWidth: 20,
|
||||
},
|
||||
immediate: false,
|
||||
beforeFetch: async (params) => {
|
||||
return Object.assign(params, { metadata: dataId.value });
|
||||
},
|
||||
afterFetch: async (res) => {
|
||||
if (res.length > 0) {
|
||||
res.forEach((item) => {
|
||||
if (item.dataValue) {
|
||||
let dataValue = JSON.parse(item.dataValue);
|
||||
item.answerScore = dataValue.answerScore;
|
||||
item.userQuery = dataValue.userQuery;
|
||||
item.promptAnswer = dataValue.promptAnswer;
|
||||
}
|
||||
});
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
});
|
||||
const [registerTable, { reload }] = tableContext;
|
||||
// 抽屉最终props
|
||||
const getProps = computed(() => {
|
||||
let drawerProps: Partial<DrawerProps> = {
|
||||
width: 1000,
|
||||
title: '调用记录',
|
||||
destroyOnClose: true,
|
||||
};
|
||||
let finalProps: Recordable = {
|
||||
...unref(attrs),
|
||||
...drawerProps,
|
||||
onCancel: closeDrawer,
|
||||
onRegister: registerDrawer,
|
||||
};
|
||||
return finalProps;
|
||||
});
|
||||
/** dataId */
|
||||
const dataId = ref<any>('');
|
||||
/** 抽屉开启 */
|
||||
function open(data) {
|
||||
dataId.value = data.record?.id || '';
|
||||
reload();
|
||||
}
|
||||
/**
|
||||
* 删除
|
||||
*/
|
||||
function handleDelete(record) {
|
||||
console.log(record, 'record');
|
||||
deleteOne({ id: record.id},reload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情
|
||||
* @param record
|
||||
*/
|
||||
function handleDetail(record) {
|
||||
openModal(true, { dataValue: record.dataValue });
|
||||
}
|
||||
/*
|
||||
* 操作栏
|
||||
*/
|
||||
function getTableAction(record) {
|
||||
return [
|
||||
{
|
||||
label: '详情',
|
||||
onClick: handleDetail.bind(null, record),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
popConfirm: {
|
||||
title: '是否确认删除',
|
||||
confirm: handleDelete.bind(null, record),
|
||||
placement: 'topLeft',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dynamic-column-config {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
max-height: calc(100vh - 100px); /* 设置最大高度为视口高度的70% */
|
||||
overflow-y: auto; /* 超出时显示垂直滚动条 */
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-extra) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.column-list {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.column-item {
|
||||
position: relative; /* 为绝对定位的操作区域提供参考 */
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f6fbff;
|
||||
}
|
||||
|
||||
/* 操作区域样式 */
|
||||
.action-area {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
|
||||
:deep(.ant-btn) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.column-form {
|
||||
flex: 1;
|
||||
width: 100%; /* 确保表单占满宽度 */
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-item {
|
||||
flex: 1;
|
||||
|
||||
&.full-width {
|
||||
flex: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
&::after {
|
||||
content: ':';
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.has-error {
|
||||
:deep(.ant-input) {
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: none; /* 隐藏原来的删除按钮区域 */
|
||||
}
|
||||
|
||||
.fixed-columns {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px dashed #d9d9d9;
|
||||
|
||||
.tip-text {
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-column-item {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-input-group-addon) {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :maxHeight="500" :width="800" @ok="handleSubmit">
|
||||
<BasicForm @register="registerForm" name="AiragPromptsForm" />
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, unref, reactive } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import { BasicForm, useForm } from '/@/components/Form/index';
|
||||
import { formSchema } from '../AiragPrompts.data';
|
||||
import { saveOrUpdate } from '../AiragPrompts.api';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { getDateByPicker } from '/@/utils';
|
||||
const { createMessage } = useMessage();
|
||||
// Emits声明
|
||||
const emit = defineEmits(['register', 'success']);
|
||||
const isUpdate = ref(true);
|
||||
const isDetail = ref(false);
|
||||
//表单配置
|
||||
const [registerForm, { setProps, resetFields, setFieldsValue, validate, scrollToField }] = useForm({
|
||||
labelWidth: 150,
|
||||
schemas: formSchema,
|
||||
showActionButtonGroup: false,
|
||||
baseColProps: { span: 24 },
|
||||
baseRowStyle: { padding: '0 20px' },
|
||||
});
|
||||
//表单赋值
|
||||
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
|
||||
//重置表单
|
||||
await resetFields();
|
||||
setModalProps({ confirmLoading: false, showCancelBtn: !!data?.showFooter, showOkBtn: !!data?.showFooter });
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
isDetail.value = !!data?.showFooter;
|
||||
if (unref(isUpdate)) {
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...data.record,
|
||||
});
|
||||
}
|
||||
// 隐藏底部时禁用整个表单
|
||||
setProps({ disabled: !data?.showFooter });
|
||||
});
|
||||
//日期个性化选择
|
||||
const fieldPickers = reactive({});
|
||||
//设置标题
|
||||
const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(isDetail) ? '详情' : '编辑'));
|
||||
//表单提交事件
|
||||
async function handleSubmit(v) {
|
||||
try {
|
||||
let values = await validate();
|
||||
// 预处理日期数据
|
||||
changeDateValue(values);
|
||||
setModalProps({ confirmLoading: true });
|
||||
//提交表单
|
||||
await saveOrUpdate(values, isUpdate.value);
|
||||
//关闭弹窗
|
||||
closeModal();
|
||||
//刷新列表
|
||||
emit('success',values);
|
||||
} catch ({ errorFields }) {
|
||||
if (errorFields) {
|
||||
const firstField = errorFields[0];
|
||||
if (firstField) {
|
||||
scrollToField(firstField.name, { behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
return Promise.reject(errorFields);
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理日期值
|
||||
* @param formData 表单数据
|
||||
*/
|
||||
const changeDateValue = (formData) => {
|
||||
if (formData && fieldPickers) {
|
||||
for (let key in fieldPickers) {
|
||||
if (formData[key]) {
|
||||
formData[key] = getDateByPicker(formData[key], fieldPickers[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/** 时间和数字输入框样式 */
|
||||
:deep(.ant-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-calendar-picker) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-bind="$attrs"
|
||||
@register="registerModal"
|
||||
:canFullscreen="true"
|
||||
:footer="null"
|
||||
destroyOnClose
|
||||
title="详情"
|
||||
wrapClassName="track-detail-modal"
|
||||
>
|
||||
<div class="track-detail-container">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="toolbar">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="toggleFormat"
|
||||
class="format-btn"
|
||||
>
|
||||
<Icon :icon="isFormatted ? 'ant-design:compress-outlined' : 'ant-design:expand-outlined'" />
|
||||
{{ isFormatted ? '收起' : '展开' }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- JSON内容显示区域 -->
|
||||
<div class="json-content-wrapper">
|
||||
<div v-if="!trackContent" class="empty-state">
|
||||
<Icon icon="ant-design:inbox-outlined" style="font-size: 48px; color: #d9d9d9;" />
|
||||
<p>暂无轨迹数据</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="json-display">
|
||||
<pre v-if="isFormatted" >{{ formattedJson }}</pre>
|
||||
<pre v-else class="raw-json">{{ trackContent }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息栏 -->
|
||||
<div v-if="trackContent" class="info-bar">
|
||||
<span class="info-item">
|
||||
<Icon icon="ant-design:info-circle-outlined" />
|
||||
{{ isFormatted ? '已格式化' : '原始数据' }}
|
||||
</span>
|
||||
<!-- <span class="info-item">-->
|
||||
<!-- <Icon icon="ant-design:file-text-outlined" />-->
|
||||
<!-- 字符数: {{ trackContent.length }}-->
|
||||
<!-- </span>-->
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import Icon from '/@/components/Icon/src/Icon.vue';
|
||||
|
||||
// 轨迹详情
|
||||
const trackContent = ref('');
|
||||
const isFormatted = ref(true);
|
||||
|
||||
// 计算属性:格式化JSON
|
||||
const formattedJson = computed(() => {
|
||||
try {
|
||||
// 尝试解析为JSON并格式化
|
||||
const jsonData = JSON.parse(trackContent.value);
|
||||
return JSON.stringify(jsonData, null, 2);
|
||||
} catch (e) {
|
||||
// 如果不是有效的JSON,返回原始内容
|
||||
return trackContent.value;
|
||||
}
|
||||
});
|
||||
|
||||
// 切换格式化状态
|
||||
const toggleFormat = () => {
|
||||
isFormatted.value = !isFormatted.value;
|
||||
};
|
||||
|
||||
// 复制JSON内容
|
||||
const copyJson = async () => {
|
||||
try {
|
||||
const content = isFormatted.value ? formattedJson.value : trackContent.value;
|
||||
await navigator.clipboard.writeText(content);
|
||||
// 这里可以添加一个轻提示
|
||||
console.log('JSON内容已复制到剪贴板');
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 表单赋值
|
||||
const [registerModal] = useModalInner(async (data) => {
|
||||
trackContent.value = data?.dataValue || '';
|
||||
// 默认显示格式化内容
|
||||
isFormatted.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@primary-color: #1890ff;
|
||||
@border-color: #e8e8e8;
|
||||
@background-color: #f5f7fa;
|
||||
@text-color: #262626;
|
||||
@text-secondary: #8c8c8c;
|
||||
|
||||
.track-detail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
// 顶部工具栏
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: @background-color;
|
||||
border-radius: 8px;
|
||||
border: 1px solid @border-color;
|
||||
|
||||
.format-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON内容显示区域
|
||||
.json-content-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: white;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: @text-secondary;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.json-display {
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.formatted-json {
|
||||
color: @text-color;
|
||||
|
||||
// JSON语法高亮样式
|
||||
&::before {
|
||||
content: '{';
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '}';
|
||||
color: #d73a49;
|
||||
}
|
||||
}
|
||||
|
||||
.raw-json {
|
||||
color: @text-secondary;
|
||||
background: #fafafa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部信息栏
|
||||
.info-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
background: @background-color;
|
||||
border-radius: 8px;
|
||||
border: 1px solid @border-color;
|
||||
font-size: 14px;
|
||||
color: @text-secondary;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
svg {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
.json-content-wrapper::-webkit-scrollbar,
|
||||
.json-display pre::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.json-content-wrapper::-webkit-scrollbar-track,
|
||||
.json-display pre::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.json-content-wrapper::-webkit-scrollbar-thumb,
|
||||
.json-display pre::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.toolbar {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.json-content-wrapper {
|
||||
padding: 12px;
|
||||
|
||||
.json-display pre {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-bar {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
// 模态框样式调整
|
||||
.track-detail-modal {
|
||||
.ant-modal-body {
|
||||
padding: 24px;
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<div class="ai-assessment-container">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="assessment-header">
|
||||
<div class="header-left">
|
||||
<h5 class="title">AI内容评估</h5>
|
||||
</div>
|
||||
<div class="header-right" v-if="variable && variable.length > 0">
|
||||
<a-button type="primary" ghost class="clear-btn" @click="handleClear">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
清空
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="loading" class="run-btn" @click="handleRun">
|
||||
<template #icon><PlayCircleOutlined /></template>
|
||||
运行
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评估内容区域 -->
|
||||
<div class="assessment-content" v-if="variable && variable.length > 0">
|
||||
<div class="input-section" v-for="item in variable" :key="item.name">
|
||||
<div class="section-header output-header">
|
||||
<span class="section-title">{{item.name}}</span>
|
||||
</div>
|
||||
<div class="section-content output-content">
|
||||
<a-input v-model:value="item.value" placeholder="请输入" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-container">
|
||||
<Empty
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="暂无变量"
|
||||
class="custom-empty"
|
||||
/>
|
||||
</div>
|
||||
<!-- 结果内容区域 -->
|
||||
<div class="debug-result-container" v-if="result">
|
||||
<!-- 调试成功提示 -->
|
||||
<div class="debug-success">
|
||||
<div class="success-header">
|
||||
<div class="success-icon">✓</div>
|
||||
<span class="success-text">调试成功</span>
|
||||
</div>
|
||||
|
||||
<!-- 分数显示 -->
|
||||
<!-- <div class="score-section">-->
|
||||
<!-- <span class="score-label">0 分</span>-->
|
||||
<!-- <div class="score-note">-->
|
||||
<!-- <span class="note-text">得分仅预览效果,非实际结果。</span>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- 原因分析 -->
|
||||
<div class="reason-section">
|
||||
<!-- <div class="reason-label">原因:</div>-->
|
||||
<div class="reason-content">
|
||||
<span>{{result}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<div class="disclaimer">
|
||||
<span class="disclaimer-text">内容由AI生成,无法确保真实准确,仅供参考。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 调试失败示例(可选显示) -->
|
||||
<div v-if="showFailureExample" class="debug-failure">
|
||||
<div class="failure-header">
|
||||
<div class="failure-icon">✗</div>
|
||||
<span class="failure-text">调试失败</span>
|
||||
</div>
|
||||
|
||||
<div class="error-message">
|
||||
<span>代码中存在语法错误,请检查后重新调试。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部说明 -->
|
||||
<!-- <div class="assessment-footer">-->
|
||||
<!-- <div class="footer-content">-->
|
||||
<!-- <InfoCircleOutlined class="footer-icon" />-->
|
||||
<!-- <span>内容由AI生成,无法确保真实准确,仅供参考。</span>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted,watch } from 'vue'
|
||||
import { Empty } from 'ant-design-vue';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
// 组件属性
|
||||
const props = defineProps({
|
||||
// 原始内容
|
||||
content: {
|
||||
type: String,
|
||||
default: ``
|
||||
},
|
||||
})
|
||||
// 控制是否显示失败示例
|
||||
const showFailureExample = ref(false)
|
||||
// 运行状态
|
||||
const loading = ref(false)
|
||||
// 响应式数据
|
||||
const variable = ref([])
|
||||
// 结果
|
||||
const result = ref("")
|
||||
// 从内容中提取参数
|
||||
const extractContent = () => {
|
||||
if(props.content){
|
||||
const vars = props.content.match(/{{\s*([^}\s]+)\s*}}/g);
|
||||
if(vars && vars.length > 0){
|
||||
variable.value = vars.map((match) => ({value:"",name:match.replace(/{{\s*|\s*}}/g, '')}))
|
||||
}
|
||||
}else{
|
||||
variable.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 清空操作
|
||||
const handleClear = () => {
|
||||
variable.value.forEach((item) => item.value = '')
|
||||
}
|
||||
|
||||
// 运行操作
|
||||
const handleRun = () => {
|
||||
// 触发运行事件
|
||||
emit('run', variable.value)
|
||||
}
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['clear', 'run'])
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
extractContent()
|
||||
})
|
||||
|
||||
// 监听内容变化
|
||||
watch(() => props.content, extractContent)
|
||||
|
||||
defineExpose({
|
||||
loading,
|
||||
result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-assessment-container {
|
||||
width: 100%;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.assessment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
color: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: #ff7875;
|
||||
border-color: #ff7875;
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.run-btn:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 内容区域样式 */
|
||||
.assessment-content {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 通用区块样式 */
|
||||
.input-section,
|
||||
.output-section {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 输出区域 */
|
||||
.output-header {
|
||||
background: #e6f7ff;
|
||||
border-bottom: 1px solid #91d5ff;
|
||||
}
|
||||
|
||||
.output-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.output-content {
|
||||
padding: 16px;
|
||||
background: #e6f7ff;
|
||||
color: #096dd9;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
|
||||
/* 底部样式 */
|
||||
.assessment-footer {
|
||||
padding: 12px 20px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.footer-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
.debug-result-container {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
/* 成功样式 */
|
||||
.debug-success {
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
background-color: #f6f8fa;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.success-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #2ea44f;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
/* 分数部分 */
|
||||
.score-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
color: #24292e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.score-note {
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 原因部分 */
|
||||
.reason-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.reason-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #24292e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.reason-content {
|
||||
font-size: 14px;
|
||||
color: #24292e;
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 免责声明 */
|
||||
.disclaimer {
|
||||
padding: 8px;
|
||||
background-color: #fff8e6;
|
||||
border: 1px solid #ffd33d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.disclaimer-text {
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 失败样式 */
|
||||
.debug-failure {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
background-color: #fcf2f2;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.failure-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.failure-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #cf222e;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.failure-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
color: #24292e;
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.assessment-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
.empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.custom-empty {
|
||||
:deep(.ant-empty-image) {
|
||||
height: 80px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-empty-description) {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
596
jeecgboot-vue3/src/views/super/airag/aiwriter/AiWriter.vue
Normal file
@@ -0,0 +1,596 @@
|
||||
<template>
|
||||
<div class="writer-wrap">
|
||||
<div class="sidebar">
|
||||
<AiWriterLeft @generate="onGenerate" />
|
||||
</div>
|
||||
<div class="preview" ref="previewRef">
|
||||
<div class="preview-header">
|
||||
<div class="preview-header-left">
|
||||
<span class="preview-title-text">{{ isEditing ? '编辑' : '预览' }}</span>
|
||||
<a-select
|
||||
v-if="historyData && historyData.length"
|
||||
v-model:value="activeVersion"
|
||||
size="small"
|
||||
class="version-select"
|
||||
@change="handleVersionChange"
|
||||
>
|
||||
<a-select-option :value="CURRENT_VERSION_KEY">当前内容</a-select-option>
|
||||
<a-select-option v-for="item in historyData" :key="item.version" :value="item.version">
|
||||
{{ item.version }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button
|
||||
v-if="historyData && historyData.length && activeVersion !== CURRENT_VERSION_KEY"
|
||||
type="link"
|
||||
size="small"
|
||||
class="preview-action-btn version-delete-btn"
|
||||
@click="handleDeleteVersion"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<a-button v-if="!generating" type="link" size="small" class="preview-action-btn custom-save-btn" @click="toggleEdit">
|
||||
<FormOutlined v-if="!isEditing" />
|
||||
<CheckOutlined v-else />
|
||||
{{ isEditing ? '完成' : '编辑' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="!generating"
|
||||
type="link"
|
||||
size="small"
|
||||
class="preview-action-btn custom-save-btn"
|
||||
:loading="polishLoading"
|
||||
@click="handlePolish"
|
||||
>
|
||||
<ThunderboltOutlined class="preview-actions-icon" style="position: relative; top: 1px" />
|
||||
润色
|
||||
</a-button>
|
||||
<a-tooltip title="保存草稿">
|
||||
<a-button v-if="!generating" type="link" size="small" class="preview-action-btn custom-save-btn" :loading="saving" @click="handleSave">
|
||||
<SaveOutlined class="preview-actions-icon" />
|
||||
保存
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="复制内容">
|
||||
<a-button type="link" size="small" class="preview-action-btn custom-save-btn" @click="handleCopy">
|
||||
<CopyOutlined class="preview-actions-icon" />
|
||||
复制
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isEditing" v-html="previewMd" class="markdown-container" @click="!generating" />
|
||||
<div v-else class="markdown-editor-container">
|
||||
<JMarkdownEditor v-model:value="writeText" height="100vh" :preview="{ mode: 'view', action: [] }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted } from 'vue';
|
||||
import AiWriterLeft from './AiWriterLeft.vue';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import { CopyOutlined, ThunderboltOutlined, FormOutlined, CheckOutlined, SaveOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { copyTextToClipboard } from '/@/hooks/web/useCopyToClipboard';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
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 JMarkdownEditor from '/@/components/Form/src/jeecg/components/JMarkdownEditor.vue';
|
||||
import '/@/views/super/airag/aiapp/chat/style/github-markdown.less';
|
||||
import '/@/views/super/airag/aiapp/chat/style/highlight.less';
|
||||
import '/@/views/super/airag/aiapp/chat/style/style.less';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const writeText = ref<string>('');
|
||||
const previewRef = ref<HTMLElement | null>(null);
|
||||
//生成的loading
|
||||
const generating = ref<boolean>(false);
|
||||
//是否编辑
|
||||
const isEditing = ref<boolean>(false);
|
||||
//润色的loading
|
||||
const polishLoading = ref<boolean>(false);
|
||||
//保存loading
|
||||
const saving = ref<boolean>(false);
|
||||
//左侧的内容
|
||||
const leftData = ref<any>();
|
||||
//历史数据
|
||||
const historyData = ref<any>([]);
|
||||
//ai提示文本
|
||||
const aiText = ref<string>('');
|
||||
//当期版本的key
|
||||
const CURRENT_VERSION_KEY = 'CURRENT';
|
||||
//当前选中的本本
|
||||
const activeVersion = ref<string>(CURRENT_VERSION_KEY);
|
||||
//原始内容
|
||||
const originalContent = ref<string | null>(null);
|
||||
//第一个回复节点之后的内容
|
||||
const afterNodeFinished = ref<boolean>(false);
|
||||
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 previewMd = computed(() => {
|
||||
let content = writeText.value || aiText.value;
|
||||
if (generating.value) {
|
||||
content +=
|
||||
' <span class="typing-dot"></span><span class="typing-dot" style="animation-delay: 0.2s"></span><span class="typing-dot" style="animation-delay: 0.4s"></span>';
|
||||
}
|
||||
return mdi.render(content);
|
||||
});
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*/
|
||||
function toggleEdit() {
|
||||
if (generating.value) {
|
||||
return;
|
||||
}
|
||||
isEditing.value = !isEditing.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制
|
||||
*/
|
||||
function handleCopy() {
|
||||
if (!writeText.value) {
|
||||
createMessage.warning('暂无内容可复制');
|
||||
return;
|
||||
}
|
||||
const success = copyTextToClipboard(writeText.value);
|
||||
if (success) {
|
||||
createMessage.success('复制成功');
|
||||
} else {
|
||||
createMessage.error('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = previewRef.value;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成
|
||||
*
|
||||
* @param data
|
||||
* @param type
|
||||
*/
|
||||
async function onGenerate(data, type = '') {
|
||||
isEditing.value = false;
|
||||
data.responseMode = 'streaming';
|
||||
writeText.value = '';
|
||||
generating.value = true;
|
||||
activeVersion.value = CURRENT_VERSION_KEY;
|
||||
if (!type) {
|
||||
leftData.value = data;
|
||||
}
|
||||
|
||||
try {
|
||||
let readableStream = await defHttp
|
||||
.post(
|
||||
{
|
||||
url: '/airag/chat/genAiWriter',
|
||||
params: { ...data },
|
||||
timeout: 5 * 60 * 1000,
|
||||
adapter: 'fetch',
|
||||
responseType: 'stream',
|
||||
},
|
||||
{
|
||||
isTransformResponse: false,
|
||||
}
|
||||
)
|
||||
.catch((res) => {
|
||||
createMessage.warn(res.message ? res.message : '请求出错,请稍后重试!');
|
||||
});
|
||||
|
||||
const reader = readableStream.getReader();
|
||||
const decoder = new TextDecoder('UTF-8');
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
let chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
// 处理可能被截断的消息
|
||||
const lines = buffer.split('\n\n');
|
||||
// 保留最后一个片段(可能不完整)
|
||||
buffer = lines.pop() || '';
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Generation error:', e);
|
||||
writeText.value += '\n\n[生成出错]';
|
||||
} finally {
|
||||
// 若服务端结束未触发 MESSAGE_END,兜底关闭生成状态
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染文本
|
||||
*
|
||||
* @param item
|
||||
*/
|
||||
function renderText(item) {
|
||||
try {
|
||||
let parse = JSON.parse(item);
|
||||
if(parse.event == 'NODE_FINISHED'){
|
||||
afterNodeFinished.value = true;
|
||||
return;
|
||||
}
|
||||
if (parse.event == 'MESSAGE') {
|
||||
aiText.value = '';
|
||||
if (afterNodeFinished.value) {
|
||||
writeText.value = parse.data.message;
|
||||
afterNodeFinished.value = false;
|
||||
} else {
|
||||
writeText.value += parse.data.message;
|
||||
}
|
||||
generating.value = true;
|
||||
polishLoading.value = true;
|
||||
nextTick(scrollToBottom);
|
||||
}
|
||||
if (parse.event == 'MESSAGE_END') {
|
||||
generating.value = false;
|
||||
if (activeVersion.value === CURRENT_VERSION_KEY) {
|
||||
originalContent.value = writeText.value;
|
||||
}
|
||||
nextTick(scrollToBottom);
|
||||
}
|
||||
if (parse.event == 'ERROR') {
|
||||
writeText.value = parse.data.message ? parse.data.message : '生成失败,请稍后重试!';
|
||||
generating.value = false;
|
||||
polishLoading.value = false;
|
||||
nextTick(scrollToBottom);
|
||||
}
|
||||
|
||||
//开始加点
|
||||
if (parse.event === 'NODE_STARTED') {
|
||||
if (!parse.data || parse.data.type !== 'end') {
|
||||
if (parse.data.type === 'llm' || parse.data.type === 'reply') {
|
||||
aiText.value = '正在构建响应内容';
|
||||
}
|
||||
}
|
||||
}
|
||||
//流程结束节点
|
||||
if (parse.event == 'FLOW_FINISHED') {
|
||||
if (parse.data && !parse.data.success) {
|
||||
writeText.value = parse.data.message ? parse.data.message : '生成失败,请稍后重试!';
|
||||
}
|
||||
generating.value = false;
|
||||
polishLoading.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error parsing update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块
|
||||
* @param str
|
||||
* @param lang
|
||||
*/
|
||||
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>`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 润色
|
||||
*/
|
||||
async function handlePolish() {
|
||||
if (!writeText.value) {
|
||||
createMessage.warning('暂无内容可润色');
|
||||
return;
|
||||
}
|
||||
if (generating.value || polishLoading.value) {
|
||||
return;
|
||||
}
|
||||
polishLoading.value = true;
|
||||
const data: any = {
|
||||
prompt: writeText.value,
|
||||
originalContent: '',
|
||||
length: leftData.value.length,
|
||||
format: leftData.value.format,
|
||||
tone: leftData.value.tone,
|
||||
language: leftData.value.language,
|
||||
activeMode: 'polish',
|
||||
};
|
||||
try {
|
||||
await onGenerate(data, 'polish');
|
||||
} finally {
|
||||
polishLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleSave() {
|
||||
if (!writeText.value) {
|
||||
createMessage.warning('暂无内容可保存');
|
||||
return;
|
||||
}
|
||||
if (saving.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
saving.value = true;
|
||||
await defHttp.post({ url: '/airag/app/save/article/write', params: { content: writeText.value } });
|
||||
if (activeVersion.value === CURRENT_VERSION_KEY) {
|
||||
originalContent.value = writeText.value;
|
||||
}
|
||||
initHistoryData();
|
||||
} catch (e) {
|
||||
createMessage.error('保存失败,请稍后重试');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化历史信息
|
||||
*/
|
||||
function initHistoryData() {
|
||||
historyData.value = [];
|
||||
defHttp.get({ url: "/airag/app/list/article/write" }, { isTransformResponse: false }).then((res)=>{
|
||||
if(res.success){
|
||||
historyData.value = res.result;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本删除
|
||||
*/
|
||||
async function handleDeleteVersion() {
|
||||
if (activeVersion.value === CURRENT_VERSION_KEY) {
|
||||
return;
|
||||
}
|
||||
const target = historyData.value.find((item) => item.version === activeVersion.value);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '删除版本',
|
||||
content: '是否确认删除该版本?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
await defHttp.delete(
|
||||
{ url: '/airag/app/delete/article/write', params: { version: target.version } },
|
||||
{ joinParamsToUrl: true }
|
||||
);
|
||||
historyData.value = historyData.value.filter((item) => item.version !== target.version);
|
||||
activeVersion.value = CURRENT_VERSION_KEY;
|
||||
writeText.value = originalContent.value ?? '';
|
||||
} catch (e) {
|
||||
createMessage.error('删除失败,请稍后重试');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本切换事件
|
||||
* @param value
|
||||
*/
|
||||
function handleVersionChange(value: string) {
|
||||
if (value === CURRENT_VERSION_KEY) {
|
||||
activeVersion.value = value;
|
||||
writeText.value = originalContent.value ?? '';
|
||||
return;
|
||||
}
|
||||
const target = historyData.value.find((item) => item.version === value);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
activeVersion.value = value;
|
||||
writeText.value = target.content;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
//初始化的时候加载历史版本
|
||||
initHistoryData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.writer-wrap {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #eef0f5;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
|
||||
background: #fff;
|
||||
overflow: visible;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
/*begin 头部样式 */
|
||||
.preview {
|
||||
flex: 1;
|
||||
border: 1px solid #eef0f5;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
overflow: auto;
|
||||
background: #fafbff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
font-size: 14px;
|
||||
color: #344767;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.preview-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-title-text {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-select {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.preview-action-btn {
|
||||
padding: 0 14px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
height: 26px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.custom-green-btn {
|
||||
background-color: @primary-color;
|
||||
border-color: @primary-color;
|
||||
color: #ffffff;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: @primary-color;
|
||||
border-color: @primary-color;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-actions-icon {
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.custom-save-btn, .version-delete-btn {
|
||||
background-color: #ffffff;
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: fade(@primary-color, 10%);
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
.preview-actions-icon {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
/*end 头部样式 */
|
||||
|
||||
/*begin 编辑器的样式*/
|
||||
.markdown-container {
|
||||
min-height: 300px;
|
||||
padding: 8px 4px 16px 4px;
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
cursor: text;
|
||||
:deep(img) {
|
||||
width: 40% !important;
|
||||
max-width: 280px;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-editor-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
:deep(.typing-dot) {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: 3px;
|
||||
border-radius: 50%;
|
||||
background-color: #999;
|
||||
vertical-align: baseline;
|
||||
animation: typing-dot 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes typing-dot {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.6;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
/*end 编辑器的样式*/
|
||||
</style>
|
||||
357
jeecgboot-vue3/src/views/super/airag/aiwriter/AiWriterLeft.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="writer-sidebar">
|
||||
<div class="mode-tabs">
|
||||
<div class="tab-item" :class="{ active: activeMode === 'compose' }" @click="activeModeClick('compose')"> 撰写 </div>
|
||||
<div class="tab-item" :class="{ active: activeMode === 'reply' }" @click="activeModeClick('reply')"> 回复 </div>
|
||||
</div>
|
||||
|
||||
<div class="form-content">
|
||||
<a-form layout="vertical">
|
||||
<template v-if="activeMode === 'reply'">
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<div style="display: flex; justify-content: space-between; width: 100%; align-items: center">
|
||||
<span>原文</span>
|
||||
<span class="example-link" @click="fillExample">示例</span>
|
||||
</div>
|
||||
</template>
|
||||
<a-textarea
|
||||
v-model:value="formData.originalContent"
|
||||
:auto-size="{ minRows: 4, maxRows: 6 }"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
placeholder="请输入原文"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="回复内容">
|
||||
<a-textarea
|
||||
v-model:value="formData.prompt"
|
||||
:auto-size="{ minRows: 4, maxRows: 6 }"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
placeholder="请输入回复内容"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<div style="display: flex; justify-content: space-between; width: 100%; align-items: center">
|
||||
<span>写作内容</span>
|
||||
<span class="example-link" @click="fillComposeExample">示例</span>
|
||||
</div>
|
||||
</template>
|
||||
<a-textarea
|
||||
v-model:value="formData.prompt"
|
||||
:auto-size="{ minRows: 6, maxRows: 12 }"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
placeholder="请输入写作内容"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item label="长度">
|
||||
<div class="tag-group">
|
||||
<span
|
||||
v-for="item in options.length"
|
||||
:key="item.value"
|
||||
class="custom-tag"
|
||||
:class="{ active: formData.length === item.value }"
|
||||
@click="formData.length = item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="格式">
|
||||
<div class="tag-group">
|
||||
<span
|
||||
v-for="item in options.format"
|
||||
:key="item.value"
|
||||
class="custom-tag"
|
||||
:class="{ active: formData.format === item.value }"
|
||||
@click="formData.format = item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="语气">
|
||||
<div class="tag-group">
|
||||
<span
|
||||
v-for="item in options.tone"
|
||||
:key="item.value"
|
||||
class="custom-tag"
|
||||
:class="{ active: formData.tone === item.value }"
|
||||
@click="formData.tone = item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="语言">
|
||||
<div class="tag-group">
|
||||
<span
|
||||
v-for="item in options.language"
|
||||
:key="item.value"
|
||||
class="custom-tag"
|
||||
:class="{ active: formData.language === item.value }"
|
||||
@click="formData.language = item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<div class="action-footer">
|
||||
<a-button @click="handleReset" style="margin-right: 10px">重置</a-button>
|
||||
<a-button type="primary" @click="handleGenerate">生成</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
|
||||
const emit = defineEmits(['generate', 'reset', 'update:modelValue']);
|
||||
const { createMessage } = useMessage();
|
||||
const activeMode = ref('compose');
|
||||
|
||||
const formData = reactive({
|
||||
prompt: '',
|
||||
originalContent: '',
|
||||
length: '自动',
|
||||
format: '自动',
|
||||
tone: '自动',
|
||||
language: '自动',
|
||||
activeMode: 'compose',
|
||||
});
|
||||
|
||||
const options = {
|
||||
//长度
|
||||
length: [
|
||||
{ label: '自动', value: '自动' },
|
||||
{ label: '短', value: '短' },
|
||||
{ label: '中等', value: '中等' },
|
||||
{ label: '长', value: '长' },
|
||||
],
|
||||
//格式化
|
||||
format: [
|
||||
{ label: '自动', value: '自动' },
|
||||
{ label: '电子邮件', value: '电子邮件' },
|
||||
{ label: '消息', value: '消息' },
|
||||
{ label: '评论', value: '评论' },
|
||||
{ label: '段落', value: '段落' },
|
||||
{ label: '文章', value: '文章' },
|
||||
{ label: '博客文章', value: '博客文章' },
|
||||
{ label: '想法', value: '想法' },
|
||||
{ label: '大纲', value: '大纲' },
|
||||
],
|
||||
//语气
|
||||
tone: [
|
||||
{ label: '自动', value: '自动' },
|
||||
{ label: '友善', value: '友善' },
|
||||
{ label: '随意', value: '随意' },
|
||||
{ label: '专业', value: '专业' },
|
||||
{ label: '诙谐', value: '诙谐' },
|
||||
{ label: '有趣', value: '有趣' },
|
||||
{ label: '正式', value: '正式' },
|
||||
],
|
||||
//语言
|
||||
language: [
|
||||
{ label: '自动', value: '自动' },
|
||||
{ label: '中文', value: '中文' },
|
||||
{ label: '英文', value: '英文' },
|
||||
{ label: '韩语', value: '韩语' },
|
||||
{ label: '日语', value: '日语' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 撰写和回复切换事件
|
||||
* @param type
|
||||
*/
|
||||
function activeModeClick(type) {
|
||||
activeMode.value = type;
|
||||
formData.originalContent = '';
|
||||
formData.prompt = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置
|
||||
*/
|
||||
function handleReset() {
|
||||
formData.originalContent = '';
|
||||
formData.prompt = '';
|
||||
formData.length = '自动';
|
||||
formData.format = '自动';
|
||||
formData.tone = '自动';
|
||||
formData.language = '自动';
|
||||
formData.activeMode = 'compose';
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成
|
||||
*/
|
||||
function handleGenerate() {
|
||||
if (activeMode.value === 'compose' && !formData.prompt) {
|
||||
createMessage.warn('请填写写作内容!');
|
||||
return;
|
||||
}
|
||||
if (activeMode.value === 'reply' && !formData.prompt) {
|
||||
createMessage.warn('请填写原文!');
|
||||
return;
|
||||
}
|
||||
if (activeMode.value === 'reply' && !formData.originalContent) {
|
||||
createMessage.warn('请填写回复!');
|
||||
return;
|
||||
}
|
||||
formData.activeMode = activeMode.value;
|
||||
emit('generate', { ...formData, mode: activeMode.value });
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复示例
|
||||
*/
|
||||
function fillExample() {
|
||||
formData.originalContent = '关于下周一项目进度会议的安排通知。';
|
||||
formData.prompt = '我已收到,下周一会准时前往';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创作示例
|
||||
*/
|
||||
function fillComposeExample() {
|
||||
formData.prompt = '介绍一下 Vue3 的整体架构';
|
||||
formData.originalContent = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.writer-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
background: #f1f3f6;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #1677ff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-footer {
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tag-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.custom-tag {
|
||||
padding: 2px 10px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: #fff;
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.example-link {
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
> label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ant-form-item-extra {
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom fix to put extra in label line if possible, but Ant Design puts extra below.
|
||||
// So I used a slot in label or handled it via absolute positioning if needed.
|
||||
// But standard ant form label slot might be better.
|
||||
// Above I used #extra slot which goes below.
|
||||
// I put example-link in #extra slot of form-item in template.
|
||||
// Actually, I should probably put it in the label slot if I want it on the same line.
|
||||
}
|
||||
</style>
|
||||
46
jeecgboot-vue3/src/views/super/airag/ocr/AiOcr.api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
export enum Api {
|
||||
list = '/airag/ocr/list',
|
||||
add = '/airag/ocr/add',
|
||||
edit = '/airag/ocr/edit',
|
||||
deleteById = '/airag/ocr/deleteById',
|
||||
flowRun = '/airag/flow/run',
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询ocr列表
|
||||
*
|
||||
* @param params
|
||||
*/
|
||||
export const list = (params) => {
|
||||
return defHttp.get({ url: Api.list, params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加Orc
|
||||
* @param params
|
||||
* @param handleSuccess
|
||||
*/
|
||||
export const addOcr = (params) => {
|
||||
return defHttp.post({ url: Api.add, params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 编辑Orc
|
||||
* @param params
|
||||
* @param handleSuccess
|
||||
*/
|
||||
export const editOcr = (params) => {
|
||||
return defHttp.put({ url: Api.edit, params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据id删除 Orc
|
||||
* @param params
|
||||
* @param handleSuccess
|
||||
*/
|
||||
export const deleteOcrById = (params) => {
|
||||
return defHttp.delete({ url: Api.deleteById, params });
|
||||
};
|
||||
85
jeecgboot-vue3/src/views/super/airag/ocr/AiOcr.data.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { BasicColumn, FormSchema } from '@/components/Table';
|
||||
|
||||
//ocr表格
|
||||
export const columns: BasicColumn[] = [
|
||||
{
|
||||
title: '编号',
|
||||
dataIndex: 'id',
|
||||
ifShow: false,
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
title: '提示词',
|
||||
dataIndex: 'prompt',
|
||||
ellipsis: true,
|
||||
width: 300,
|
||||
},
|
||||
];
|
||||
|
||||
//ocr表单
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
label: '',
|
||||
field: 'id',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: '标题',
|
||||
field: 'title',
|
||||
component: 'Input',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: '提示词',
|
||||
field: 'prompt',
|
||||
component: 'InputTextArea',
|
||||
componentProps: {
|
||||
row: 4,
|
||||
autosize: { minRows: 4, maxRows: 6 },
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
//ocr解析表单
|
||||
export const analysisSchemas: FormSchema[] = [
|
||||
{
|
||||
label: '',
|
||||
field: 'id',
|
||||
component: 'Input',
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: '图片',
|
||||
field: 'url',
|
||||
component: 'JImageUpload',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: '提示词',
|
||||
field: 'prompt',
|
||||
component: 'InputTextArea',
|
||||
componentProps: {
|
||||
row: 2,
|
||||
autosize: { minRows: 2, maxRows: 2 },
|
||||
placeholder:"请填写提示词,如提取图片中的文字"
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '解析结果',
|
||||
field: 'analysisResult',
|
||||
component: 'InputTextArea',
|
||||
componentProps: {
|
||||
row: 10,
|
||||
autosize: { minRows: 10, maxRows: 10 },
|
||||
readonly: true,
|
||||
placeholder:"解析结果将在这里显示"
|
||||
},
|
||||
},
|
||||
];
|
||||