first commit
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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',
|
||||
queryFlowById = '/airag/flow/queryById',
|
||||
promptGenerate = '/airag/app/prompt/generate',
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询应用
|
||||
* @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 });
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据应用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 });
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用编排
|
||||
* @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,
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,597 @@
|
||||
<!--知识库文档列表-->
|
||||
<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="20" 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="18" 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="16" 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="16"></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="16"></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: 40px;
|
||||
height: 40px;
|
||||
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>
|
||||
@@ -0,0 +1,407 @@
|
||||
<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"
|
||||
></chat>
|
||||
</div>
|
||||
</template>
|
||||
<Spin v-else :spinning="true"></Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import slide from './slide.vue';
|
||||
import chat from './chat.vue';
|
||||
import { Spin } from 'ant-design-vue';
|
||||
import { ref, watch, nextTick, onUnmounted, onMounted } 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";
|
||||
|
||||
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 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);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// 监听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;
|
||||
});
|
||||
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) {
|
||||
chatData.value = res.result;
|
||||
} else {
|
||||
chatData.value = [];
|
||||
}
|
||||
chatVisible.value = false;
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
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: 999;
|
||||
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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="chat" :class="[inversion === 'user' ? 'self' : 'chatgpt']" v-if="getText || (props.presetQuestion && props.presetQuestion.length>0)">
|
||||
<div class="avatar">
|
||||
<img v-if="inversion === 'user'" :src="avatar()" />
|
||||
<img v-else :src="getAiImg()" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<p class="date">
|
||||
<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 === '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 class="msgArea" v-if="!isCard">
|
||||
<chatText :text="text" :inversion="inversion" :error="error" :loading="loading" :referenceKnowledge="referenceKnowledge"></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 defaultAvatar from "@/assets/images/ai/avatar.jpg";
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import defaultImg from '../img/ailogo.png';
|
||||
|
||||
const props = defineProps(['dateTime', 'text', 'inversion', 'error', 'loading','appData','presetQuestion','images','retrievalText', 'referenceKnowledge']);
|
||||
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
|
||||
import { createImgPreview } from "@/components/Preview";
|
||||
import { computed } from "vue";
|
||||
|
||||
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 { 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){
|
||||
return 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');
|
||||
}
|
||||
</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;
|
||||
}
|
||||
.date {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
.avatar {
|
||||
flex: none;
|
||||
margin-right: 10px;
|
||||
img {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
svg {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
.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>
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div v-if="text != ''" class="textWrap" :class="[inversion === 'user' ? 'self' : 'chatgpt']" ref="textRef">
|
||||
<div v-if="inversion != 'user'" :style="{ width: getIsMobile? screenWidth : 'auto' }">
|
||||
<div class="markdown-body" :class="{ 'markdown-body-generate': loading }" :style="{color:error?'#FF4444 !important':''}" v-html="text" />
|
||||
<template v-if="showRefKnow">
|
||||
<a-divider orientation="left">引用</a-divider>
|
||||
<template v-for="(item, idx) of referenceKnowledge" :key="idx">
|
||||
<a-tooltip :title="item.substring(0, 800)">
|
||||
<a-tag >
|
||||
<a-space>
|
||||
<img :src="knowledgePng" width="16" height="16"/>
|
||||
<div style="max-width: 240px; overflow: hidden;white-space: nowrap;text-overflow: ellipsis;">
|
||||
{{ item }}
|
||||
</div>
|
||||
</a-space>
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="msg" v-html="text" />
|
||||
</div>
|
||||
<ImageViewer v-if="amplifyImage" :imageUrl="imageUrl" @hide="pictureHide"></ImageViewer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, onUpdated, ref } from '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 './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 knowledgePng from '../../aiknowledge/icon/knowledge.png'
|
||||
|
||||
/**
|
||||
* 屏幕宽度
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background-color: #f4f6f8;
|
||||
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
//手机和平板下的样式
|
||||
.textWrap{
|
||||
margin-left: -40px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
</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,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,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,65 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 注册路由 */
|
||||
export async function register(app: App) {
|
||||
await registerMyAppRouter(app);
|
||||
console.log('[聊天路由] 注册完成!');
|
||||
}
|
||||
|
||||
async function registerMyAppRouter(_: App) {
|
||||
for(let appRoute of ChatRoutes){
|
||||
await router.addRoute(appRoute);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
敲敲云
|
||||
</a>
|
||||
提供
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
import { getFileAccessHttpUrl } from '@/utils/common/compUtils';
|
||||
import defaultImg from '../img/ailogo.png';
|
||||
const props = defineProps(['dataSource', 'appData','source']);
|
||||
const emit = defineEmits(['save', 'click', 'reloadRight', 'prologue']);
|
||||
const inputRef = ref(null);
|
||||
const router = useRouter();
|
||||
let inputValue = '';
|
||||
//新建聊天
|
||||
const handleCreate = () => {
|
||||
const uuid = getUuid();
|
||||
props.dataSource.history.unshift({ title: '新建聊天', id: uuid, isEdit: false, disabled: true });
|
||||
// 新建第一个(需要高亮选中)
|
||||
props.dataSource.active = uuid;
|
||||
emit('click', "新建聊天", 0);
|
||||
};
|
||||
// 切换聊天
|
||||
const handleToggleChat = (item, index) => {
|
||||
if (item.id != props.dataSource.active) {
|
||||
props.dataSource.active = item.id;
|
||||
emit('click', item.title, index);
|
||||
}
|
||||
};
|
||||
const handleInputChange = (e) => {
|
||||
inputValue = e.target.value.trim();
|
||||
};
|
||||
// 失去焦点
|
||||
const inputBlur = (item) => {
|
||||
item.isEdit = false;
|
||||
item.title = inputValue;
|
||||
defHttp
|
||||
.put(
|
||||
{
|
||||
url: '/airag/chat/conversation/update/title',
|
||||
params: { id: item.id, title: inputValue },
|
||||
},
|
||||
{ joinParamsToUrl: true }
|
||||
)
|
||||
.then((res) => {});
|
||||
};
|
||||
// 编辑
|
||||
const handleEdit = (item) => {
|
||||
console.log(item);
|
||||
item.isEdit = true;
|
||||
inputValue = item.title;
|
||||
};
|
||||
// 保存
|
||||
const handleSave = (item) => {
|
||||
item.isEdit = false;
|
||||
item.title = inputValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除
|
||||
* @param data
|
||||
*/
|
||||
function handleDel(data) {
|
||||
const findIndex = props.dataSource.history.findIndex((item) => item.id == data.id);
|
||||
if (findIndex != -1) {
|
||||
props.dataSource.history.splice(findIndex, 1);
|
||||
// 删除的是当前active的,active往前移,前面没了往后移。
|
||||
if (props.dataSource.history.length) {
|
||||
if (props.dataSource.active == data.id) {
|
||||
if (findIndex > 0) {
|
||||
props.dataSource.active = props.dataSource.history[findIndex - 1].id;
|
||||
} else {
|
||||
props.dataSource.active = props.dataSource.history[0].id;
|
||||
}
|
||||
}
|
||||
emit('click', props.dataSource.history[0].title, findIndex);
|
||||
} else {
|
||||
// 删没了(删除了最后一个)
|
||||
handleCreate();
|
||||
}
|
||||
}
|
||||
//update-begin---author:wangshuai---date:2025-03-12---for:【QQYUN-11560】新建聊天内容为空,无法删除---
|
||||
if(data.disabled){
|
||||
return;
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-12---for:【QQYUN-11560】新建聊天内容为空,无法删除---
|
||||
defHttp.delete({
|
||||
url: '/airag/chat/conversation/' + data.id,
|
||||
},{ isTransformResponse: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片
|
||||
*/
|
||||
function getImage() {
|
||||
return props.appData.icon ? getFileAccessHttpUrl(props.appData.icon) : defaultImg;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => inputRef.value,
|
||||
(newVal: any) => {
|
||||
if (newVal?.length) {
|
||||
newVal[0].focus();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 指定长度和基数
|
||||
const getUuid = (len = 10, radix = 16) => {
|
||||
var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
|
||||
var uuid: any = [],
|
||||
i;
|
||||
radix = radix || chars.length;
|
||||
|
||||
if (len) {
|
||||
for (i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)];
|
||||
} else {
|
||||
var r;
|
||||
uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
|
||||
uuid[14] = '4';
|
||||
for (i = 0; i < 36; i++) {
|
||||
if (!uuid[i]) {
|
||||
r = 0 | (Math.random() * 16);
|
||||
uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r];
|
||||
}
|
||||
}
|
||||
}
|
||||
return uuid.join('');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.slide-wrap {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.historyArea {
|
||||
padding: 20px;
|
||||
padding-top: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
margin-bottom: 20px;
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
.historyArea ul li:hover {
|
||||
.del {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.createArea {
|
||||
padding: 20px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.ant-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.list {
|
||||
width: 100%;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
&:hover,
|
||||
&.active {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
.edit,
|
||||
.save,
|
||||
.del {
|
||||
display: none;
|
||||
}
|
||||
&.active {
|
||||
.edit,
|
||||
.save,
|
||||
.del {
|
||||
display: block;
|
||||
}
|
||||
&.last {
|
||||
.del {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.message {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.edit {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
&.ant-input {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
:deep(.ant-popover) {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
:deep(.ant-popconfirm) {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 20px 4px 0 4px;
|
||||
margin-left: 16px;
|
||||
.header-image {
|
||||
height: 35px;
|
||||
width: 35px;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.header-name {
|
||||
align-self: center;
|
||||
color: #1d2939;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
.left-footer{
|
||||
display:flex;
|
||||
margin-right: 20px;
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 50px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
||||
html.dark {
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
color: #abb2bf;
|
||||
background: #282c34;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-operator,
|
||||
.hljs-pattern-match {
|
||||
color: #f92672;
|
||||
}
|
||||
|
||||
.hljs-function,
|
||||
.hljs-pattern-match .hljs-constructor {
|
||||
color: #61aeee;
|
||||
}
|
||||
|
||||
.hljs-function .hljs-params {
|
||||
color: #a6e22e;
|
||||
}
|
||||
|
||||
.hljs-function .hljs-params .hljs-typing {
|
||||
color: #fd971f;
|
||||
}
|
||||
|
||||
.hljs-module-access .hljs-module {
|
||||
color: #7e57c2;
|
||||
}
|
||||
|
||||
.hljs-constructor {
|
||||
color: #e2b93d;
|
||||
}
|
||||
|
||||
.hljs-constructor .hljs-string {
|
||||
color: #9ccc65;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #b18eb1;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-formula {
|
||||
color: #c678dd;
|
||||
}
|
||||
|
||||
.hljs-deletion,
|
||||
.hljs-name,
|
||||
.hljs-section,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: #56b6c2;
|
||||
}
|
||||
|
||||
.hljs-addition,
|
||||
.hljs-attribute,
|
||||
.hljs-meta .hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-string {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-class .hljs-title,
|
||||
.hljs-title.class_ {
|
||||
color: #e6c07b;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-number,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable {
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
.hljs-bullet,
|
||||
.hljs-link,
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-symbol,
|
||||
.hljs-title {
|
||||
color: #61aeee;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
padding: 3px 5px;
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.hljs {
|
||||
color: #383a42;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #a0a1a7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-formula,
|
||||
.hljs-keyword {
|
||||
color: #a626a4;
|
||||
}
|
||||
|
||||
.hljs-deletion,
|
||||
.hljs-name,
|
||||
.hljs-section,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #e45649;
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: #0184bb;
|
||||
}
|
||||
|
||||
.hljs-addition,
|
||||
.hljs-attribute,
|
||||
.hljs-meta .hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-string {
|
||||
color: #50a14f;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-number,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable {
|
||||
color: #986801;
|
||||
}
|
||||
|
||||
.hljs-bullet,
|
||||
.hljs-link,
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-symbol,
|
||||
.hljs-title {
|
||||
color: #4078f2;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-class .hljs-title,
|
||||
.hljs-title.class_ {
|
||||
color: #c18401;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -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,331 @@
|
||||
<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="loadFlowData"
|
||||
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 flowList" @click="handleSelect(item)">
|
||||
<a-card :style="item.id === flowId ? { border: '1px solid #3370ff' } : {}" hoverable class="checkbox-card" :body-style="{ width: '100%' }">
|
||||
<div style="display: flex; width: 100%;align-items:center">
|
||||
<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>
|
||||
<div class="text-desc mt-10">
|
||||
{{ item.descr || '暂无描述' }}
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div v-if="flowId" class="use-select">
|
||||
已选择 <span class="ellipsis" style="max-width: 150px">{{flowData.name}}</span>
|
||||
<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 } 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';
|
||||
import {pageApi} from "@/views/super/airag/aiflow/pages/api";
|
||||
import { getFileAccessHttpUrl } from "@/utils/common/compUtils";
|
||||
import defaultFlowImg from "@/assets/images/ai/aiflow.png";
|
||||
|
||||
export default {
|
||||
name: 'AiAppAddFlowModal',
|
||||
components: {
|
||||
Pagination,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
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) => {
|
||||
flowId.value = data.flowId ? cloneDeep(data.flowId) : '';
|
||||
flowData.value = data.flowData ? cloneDeep(data.flowData) : {};
|
||||
setModalProps({ minHeight: 500, bodyStyle: { padding: '10px' } });
|
||||
loadFlowData();
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
emit('success',{ flowId: flowId.value, flowData: flowData.value });
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
//复选框选中事件
|
||||
const handleSelect = (item) => {
|
||||
if(flowId.value === item.id){
|
||||
flowId.value = "";
|
||||
flowData.value = null;
|
||||
return;
|
||||
}
|
||||
flowId.value = item.id;
|
||||
flowData.value = item;
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载AI流程
|
||||
*/
|
||||
function loadFlowData() {
|
||||
let params = {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
column: 'createTime',
|
||||
order: 'desc',
|
||||
name: searchText.value,
|
||||
status: 'enable,release'
|
||||
};
|
||||
pageApi.list(params).then((res) =>{
|
||||
if(res){
|
||||
for (const data of res.records) {
|
||||
data.metadata = getMetadata(data.metadata);
|
||||
}
|
||||
flowList.value = res.records;
|
||||
total.value = res.total;
|
||||
} else {
|
||||
flowList.value = [];
|
||||
total.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页改变事件
|
||||
* @param page
|
||||
* @param current
|
||||
*/
|
||||
function handlePageChange(page, current) {
|
||||
pageNo.value = page;
|
||||
pageSize.value = current;
|
||||
loadFlowData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空选中状态
|
||||
*/
|
||||
function handleClearClick() {
|
||||
flowId.value = "";
|
||||
flowData.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图标
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
flowList,
|
||||
flowId,
|
||||
handleSelect,
|
||||
pageNo,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
total,
|
||||
handlePageChange,
|
||||
knowledge,
|
||||
searchText,
|
||||
loadFlowData,
|
||||
handleClearClick,
|
||||
flowData,
|
||||
getImage,
|
||||
};
|
||||
},
|
||||
};
|
||||
</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: 260px;
|
||||
}
|
||||
.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%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<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="item.checked ? { border: '1px solid #3370ff' } : {}" 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-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.length > 0" class="use-select">
|
||||
已选择 {{ knowledgeIds.length }} 知识库
|
||||
<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'],
|
||||
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) => {
|
||||
knowledgeIds.value = data.knowledgeIds ? cloneDeep(data.knowledgeIds.split(',')) : [];
|
||||
knowledgeData.value = data.knowledgeDataList ? cloneDeep(data.knowledgeDataList) : [];
|
||||
setModalProps({ minHeight: 500, bodyStyle: { padding: '10px' } });
|
||||
loadKnowledgeData();
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
console.log("知识库确定选中的值",knowledgeData.value);
|
||||
emit('success', knowledgeIds.value, knowledgeData.value);
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
//复选框选中事件
|
||||
function handleSelect(item){
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载知识库
|
||||
*/
|
||||
function loadKnowledgeData() {
|
||||
let params = {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
name: searchText.value,
|
||||
};
|
||||
list(params).then((res) => {
|
||||
if (res.success) {
|
||||
if (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() {
|
||||
knowledgeIds.value = [];
|
||||
knowledgeData.value = [];
|
||||
appKnowledgeOption.value.forEach((item) => {
|
||||
item.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 复选框选中事件
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
appKnowledgeOption,
|
||||
knowledgeIds,
|
||||
handleSelect,
|
||||
pageNo,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
total,
|
||||
handlePageChange,
|
||||
knowledge,
|
||||
searchText,
|
||||
loadKnowledgeData,
|
||||
handleClearClick,
|
||||
handleChange,
|
||||
};
|
||||
},
|
||||
};
|
||||
</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;
|
||||
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,312 @@
|
||||
<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">指令</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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModalInner } from '@/components/Modal';
|
||||
import { promptGenerate } from '@/views/super/airag/aiapp/AiApp.api';
|
||||
|
||||
export default {
|
||||
name: 'AiAppGeneratedPrompt',
|
||||
components: {
|
||||
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 [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: 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();
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
handleOk,
|
||||
handleCancel,
|
||||
prompt,
|
||||
generatedPrompt,
|
||||
instructionsList,
|
||||
loading,
|
||||
instructionsClick,
|
||||
content,
|
||||
};
|
||||
},
|
||||
};
|
||||
</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 {
|
||||
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,95 @@
|
||||
<!--手动录入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;
|
||||
}
|
||||
}else{
|
||||
if(!data.metadata.hasOwnProperty("topNumber") ){
|
||||
data.metadata['topNumber'] = 4;
|
||||
}
|
||||
if(!data.metadata.hasOwnProperty("similarity") ){
|
||||
data.metadata['similarity'] = 0.76;
|
||||
}
|
||||
}
|
||||
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,100 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" width="800px" :title="title" @ok="handleOk" @cancel="handleCancel">
|
||||
<BasicForm @register="registerForm"></BasicForm>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModalInner } from '@/components/Modal';
|
||||
|
||||
import BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { useForm } from '@/components/Form';
|
||||
import { quickCommandFormSchema} from '../AiApp.data';
|
||||
|
||||
export default {
|
||||
name: 'AiAppQuickCommandModal',
|
||||
components: {
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['ok', 'update-ok', 'register'],
|
||||
setup(props, { emit }) {
|
||||
const title = ref<string>('添加指令');
|
||||
|
||||
//保存或修改
|
||||
const isUpdate = ref<boolean>(false);
|
||||
|
||||
//表单配置
|
||||
const [registerForm, { validate, resetFields, setFieldsValue }] = useForm({
|
||||
schemas: quickCommandFormSchema,
|
||||
showActionButtonGroup: false,
|
||||
layout: 'vertical',
|
||||
wrapperCol: { span: 24 },
|
||||
});
|
||||
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
await resetFields();
|
||||
isUpdate.value = !!data?.isUpdate;
|
||||
if (unref(isUpdate)) {
|
||||
//表单赋值
|
||||
await setFieldsValue({
|
||||
...data.record,
|
||||
});
|
||||
}
|
||||
setModalProps({ minHeight: 200, bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存
|
||||
*/
|
||||
async function handleOk() {
|
||||
try {
|
||||
let values = await validate();
|
||||
setModalProps({ confirmLoading: true });
|
||||
if(isUpdate.value){
|
||||
emit('update-ok',values);
|
||||
}else{
|
||||
emit('ok', values);
|
||||
}
|
||||
handleCancel();
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
function handleCancel() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
registerForm,
|
||||
title,
|
||||
handleOk,
|
||||
handleCancel
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-title {
|
||||
color: #1d2025;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.type-desc {
|
||||
color: #8f959e;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<BasicModal destroyOnClose @register="registerModal" :canFullscreen="false" :width="width" :title="title" :footer="null">
|
||||
<!-- 嵌入表单 -->
|
||||
<div v-if="type === 'menu'">
|
||||
<a-form layout="vertical" :model="appData">
|
||||
<a-form-item label="菜单名称">
|
||||
<a-input v-model:value="appData.name" readonly/>
|
||||
</a-form-item>
|
||||
<a-form-item label="菜单地址">
|
||||
<a-input v-model:value="appData.menu" readonly/>
|
||||
</a-form-item>
|
||||
<a-form-item style="text-align:right">
|
||||
<a-button @click.prevent="copyMenu">复制菜单</a-button>
|
||||
<a-button type="primary" style="margin-left: 10px" @click="copySql">复制SQL</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
<!-- 嵌入网站 -->
|
||||
<div v-else-if="type === 'web'" class="web">
|
||||
|
||||
<div style="display: flex;margin: 0 auto">
|
||||
<div :class="activeKey===1?'active':''" class="web-img" @click="handleImageClick(1)">
|
||||
<img src="../img/webEmbedded.png" />
|
||||
</div>
|
||||
<div style="margin-left: 10px" :class="activeKey===2?'active':''" class="web-img" @click="handleImageClick(2)">
|
||||
<img src="../img/iconWebEmbedded.png" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="web-title" v-if="activeKey === 1">
|
||||
将以下 iframe 嵌入到你的网站中的目标位置
|
||||
</div>
|
||||
<div class="web-title" v-else>
|
||||
将以下 script 添加到网页的body区域中
|
||||
</div>
|
||||
<div class="web-code" v-if="activeKey === 1">
|
||||
<div class="web-code-title">
|
||||
<div class="web-code-desc">
|
||||
html
|
||||
</div>
|
||||
<Icon class="pointer" icon="ant-design:copy-outlined" @click="copyIframe(1)"></Icon>
|
||||
</div>
|
||||
<div class="web-code-iframe">
|
||||
<pre> {{getIframeText(1)}} </pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="web-code" v-if="activeKey === 2">
|
||||
<div class="web-code-title">
|
||||
<div class="web-code-desc">
|
||||
html
|
||||
</div>
|
||||
<Icon class="pointer" icon="ant-design:copy-outlined" @click="copyIframe(2)"></Icon>
|
||||
</div>
|
||||
<div class="web-code-iframe">
|
||||
<pre> {{getIframeText(2)}} </pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, unref } from 'vue';
|
||||
import BasicModal from '@/components/Modal/src/BasicModal.vue';
|
||||
import { useModalInner } from '@/components/Modal';
|
||||
|
||||
import BasicForm from '@/components/Form/src/BasicForm.vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { buildUUID } from '@/utils/uuid';
|
||||
import { copyTextToClipboard } from '@/hooks/web/useCopyToClipboard';
|
||||
import { isDevMode } from '/@/utils/env';
|
||||
|
||||
export default {
|
||||
name: 'AiAppSendModal',
|
||||
components: {
|
||||
BasicForm,
|
||||
BasicModal,
|
||||
},
|
||||
emits: ['success', 'register'],
|
||||
setup(props, { emit }) {
|
||||
//标题
|
||||
const title = ref<string>('嵌入网站');
|
||||
const $message = useMessage();
|
||||
//类型
|
||||
const type = ref<string>('web');
|
||||
//应用信息
|
||||
const appData = ref<any>({});
|
||||
//弹窗宽度
|
||||
const width = ref<string>("800px");
|
||||
//选中的key
|
||||
const activeKey = ref<number>(1);
|
||||
//注册modal
|
||||
const [registerModal, { closeModal, setModalProps }] = useModalInner(async (data) => {
|
||||
type.value = data.type;
|
||||
appData.value = data.data;
|
||||
appData.value.menu = "/ai/chat/"+ data.data.id
|
||||
activeKey.value = 1;
|
||||
let minHeight = 220;
|
||||
if(data.type === 'web'){
|
||||
title.value = '嵌入网站';
|
||||
width.value = '640px';
|
||||
minHeight = 500
|
||||
}else{
|
||||
title.value = '配置菜单';
|
||||
width.value = '500px';
|
||||
}
|
||||
setModalProps({ height: minHeight, bodyStyle: { padding: '10px' } });
|
||||
});
|
||||
|
||||
/**
|
||||
* 复制菜单
|
||||
*/
|
||||
function copyMenu() {
|
||||
copyText(appData.value.menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制sql
|
||||
*/
|
||||
function copySql() {
|
||||
const insertMenuSql = `INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
|
||||
VALUES ('${buildUUID()}', NULL, '${appData.value.name}', '${appData.value.menu}', '1', NULL, NULL, 0, NULL, '1', 0.00, 0, NULL, 0, 1, 0, 0, 0, NULL, '1', 0, 0, 'admin', null, NULL, NULL, 0)`;
|
||||
copyText(insertMenuSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前文本
|
||||
*/
|
||||
function getIframeText(value) {
|
||||
let locationUrl = document.location.protocol +"//" + window.location.host;
|
||||
//update-begin---author:wangshuai---date:2025-03-20---for:【QQYUN-11649】【AI】应用嵌入,支持一个小图标点击出聊天---
|
||||
if(value === 1){
|
||||
return '<iframe\n' +
|
||||
' src="'+locationUrl+'/ai/app/chat/'+appData.value.id+'"\n' +
|
||||
' style="width: 100%; height: 100%;">\n' +
|
||||
'</iframe>';
|
||||
}else{
|
||||
//update-begin---author:wangshuai---date:2025-03-28---for:【QQYUN-11649】应用嵌入,支持一个小图标点击出聊天---
|
||||
let path = "/src/views/super/airag/aiapp/chat/js/chat.js"
|
||||
if(!isDevMode()){
|
||||
path = "/chat/chat.js";
|
||||
}
|
||||
let text ='<script src=' + locationUrl + path +' id="e7e007dd52f67fe36365eff636bbffbd">'+'<'+'/script>';
|
||||
text += '\n <'+'script>\n';
|
||||
text += ' createAiChat({\n' +
|
||||
' appId:"'+ appData.value.id +'",\n';
|
||||
text += ' // 支持top-left左上, top-right右上, bottom-left左下, bottom-right右下\n';
|
||||
text += ' iconPosition:"bottom-right"\n';
|
||||
text += ' })\n';
|
||||
text += ' <'+'/script>';
|
||||
return text;
|
||||
//update-end---author:wangshuai---date:2025-03-28---for:【QQYUN-11649】应用嵌入,支持一个小图标点击出聊天---
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-20---for:【QQYUN-11649】【AI】应用嵌入,支持一个小图标点击出聊天---
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制iframe
|
||||
*/
|
||||
function copyIframe(value) {
|
||||
copyText(getIframeText(value));
|
||||
}
|
||||
|
||||
// 复制文本到剪贴板
|
||||
function copyText(text: string) {
|
||||
const success = copyTextToClipboard(text);
|
||||
if (success) {
|
||||
$message.createMessage.success('复制成功!');
|
||||
} else {
|
||||
$message.createMessage.error('复制失败!');
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片点击事件
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleImageClick(value) {
|
||||
activeKey.value = value;
|
||||
}
|
||||
|
||||
return {
|
||||
registerModal,
|
||||
title,
|
||||
type,
|
||||
appData,
|
||||
copySql,
|
||||
copyMenu,
|
||||
width,
|
||||
copyIframe,
|
||||
getIframeText,
|
||||
activeKey,
|
||||
handleImageClick,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.type-title {
|
||||
color: #1d2025;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.type-desc {
|
||||
color: #8f959e;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.web{
|
||||
padding: 0 10px;
|
||||
}
|
||||
.web-title{
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
}
|
||||
.web-img{
|
||||
border-width: 1.5px;
|
||||
width: 240px;
|
||||
margin-top: 20px;
|
||||
border-radius: 6px;
|
||||
img{
|
||||
border-radius: 6px;
|
||||
width: 240px;
|
||||
height: 150px;
|
||||
}
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.active{
|
||||
border-color: rgb(41 112 255);
|
||||
}
|
||||
.web-code{
|
||||
border-width: 1.5px;
|
||||
margin-top: 20px;
|
||||
background-color: #f9fafb;
|
||||
border-color: #10182814;
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
.web-code-title{
|
||||
width: 100%;
|
||||
padding:10px;
|
||||
background-color: #f2f4f7;
|
||||
display: inline-flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.web-code-desc{
|
||||
color: #354052;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
}
|
||||
.web-code-iframe{
|
||||
padding: 15px;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #354052;
|
||||
}
|
||||
}
|
||||
.pointer{
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,295 @@
|
||||
import {defHttp} from "@/utils/http/axios";
|
||||
import {useMessage} from '/@/hooks/web/useMessage';
|
||||
import dayjs from "dayjs";
|
||||
|
||||
enum Api {
|
||||
// 运行流程(需要保存)
|
||||
run = '/airag/flow/run',
|
||||
// 调试流程(不需要保存)
|
||||
debug = '/airag/flow/debug',
|
||||
// invoke = '/airag/flow/invoke',
|
||||
// 获取流程列表
|
||||
list = '/airag/flow/list',
|
||||
// 添加流程
|
||||
add = '/airag/flow/add',
|
||||
// 编辑流程(不包含design数据)
|
||||
edit = '/airag/flow/edit',
|
||||
// 仅保存流程设计
|
||||
designSave = '/airag/flow/design/save',
|
||||
// 通过id删除流程
|
||||
deleteById = '/airag/flow/delete',
|
||||
// 批量删除流程
|
||||
deleteBatch = '/airag/flow/deleteBatch',
|
||||
|
||||
// 获取子流程列表(包括入参)
|
||||
subflowList = '/airag/flow/subflowList',
|
||||
// 根据ID获取单个子流程
|
||||
querySubflowById = '/airag/flow/querySubflowById',
|
||||
}
|
||||
|
||||
const {createMessage: $message} = useMessage();
|
||||
|
||||
// 运行流程超时时间(5分钟)
|
||||
const runTimeout = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 阻塞式运行流程
|
||||
* @param flowId
|
||||
* @param inputParams
|
||||
*/
|
||||
export async function blockRun(flowId: string, inputParams: Recordable) {
|
||||
return defHttp.post({
|
||||
url: Api.run,
|
||||
params: {
|
||||
flowId: flowId,
|
||||
inputParams: inputParams,
|
||||
responseMode: 'blocking'
|
||||
},
|
||||
timeout: runTimeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式运行流程
|
||||
// * @param flowId NY5LzSY2VW1BSthYSnJArCFqbgwtZqSuyPQ/OD1n1twWJGU2RN/wkzf+kBVO5DztN85Ca9keeuaRAiwcatr8N0M15+Wv2SmRw82lMwawE2naX5tpJMpkxrUhUUcjnC+BSBL4+PV2JUXBFW8/oOG8HLqYvmPxoP7MMBhMi9D7lRY=
|
||||
* @param flowRecord
|
||||
* @param inputParams
|
||||
*/
|
||||
// export function createStreamRun(flowId: string, inputParams: Recordable) {
|
||||
export function createStreamRun(/*flowId: string, */ flowRecord: Recordable, inputParams: Recordable) {
|
||||
type RunEventType = 'FLOW_STARTED' | 'NODE_STARTED' | 'NODE_FINISHED' | 'FLOW_FINISHED' | 'MESSAGE';
|
||||
|
||||
const logColor = (text: string, color: string) => [`%c${text}`, `color: ${color};`];
|
||||
const lcInfo = () => logColor('[INFO]', '#2196F3');
|
||||
const lcWarn = () => logColor('[WARN]', '#FFC107');
|
||||
const lcError = () => logColor('[ERRO]', '#F44336');
|
||||
|
||||
const colorTextRun = logColor('[stream-run]', '#999999');
|
||||
const debugStreamRun = (lcFn: Fn, ...args: any[]) => {
|
||||
const colorTextType: string[] = lcFn();
|
||||
const colorText = [
|
||||
`${colorTextRun[0]} ${colorTextType[0]}`,
|
||||
colorTextRun[1], colorTextType[1],
|
||||
];
|
||||
const dateText = dayjs().format('HH:mm:ss.SSS');
|
||||
console.debug(...colorText, `[${dateText}]`, ...args);
|
||||
};
|
||||
|
||||
// 记录解析失败的数据
|
||||
let failChunkText = '';
|
||||
|
||||
// 发送请求
|
||||
async function send() {
|
||||
const readableStream = await defHttp.post({
|
||||
url: Api.debug,
|
||||
params: {
|
||||
// flowId: flowId,
|
||||
flow: flowRecord,
|
||||
inputParams: inputParams,
|
||||
responseMode: 'streaming',
|
||||
},
|
||||
adapter: 'fetch',
|
||||
responseType: 'stream',
|
||||
timeout: runTimeout,
|
||||
}, {
|
||||
isTransformResponse: false,
|
||||
});
|
||||
|
||||
const reader = readableStream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
failChunkText = '';
|
||||
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
const chunkText = decoder.decode(value, {stream: true});
|
||||
try {
|
||||
debugStreamRun(lcInfo, `收到 chunkText:`, {chunkText});
|
||||
handleChunkText(chunkText);
|
||||
} catch (error) {
|
||||
console.error('Error parsing update:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 chunkText
|
||||
* @param chunkText
|
||||
*/
|
||||
function handleChunkText(chunkText: string) {
|
||||
if (!chunkText) {
|
||||
debugStreamRun(lcError, 'chunkText 为空:', {chunkText});
|
||||
return;
|
||||
}
|
||||
// 如果包含解析失败的数据,则合并再解析
|
||||
const hasFailChunkText = failChunkText.length > 0;
|
||||
if (hasFailChunkText) {
|
||||
chunkText = failChunkText + chunkText;
|
||||
debugStreamRun(lcInfo, '合并解析失败的数据:', {chunkText});
|
||||
failChunkText = '';
|
||||
}
|
||||
let hasFailChunk = false;
|
||||
const chunks = chunkText.split('\n').flatMap((chunk: string) => {
|
||||
chunk = chunk ? chunk.trim() : '';
|
||||
if (!chunk) {
|
||||
return []
|
||||
}
|
||||
if (chunk.startsWith('data:')) {
|
||||
chunk = chunk.slice(5);
|
||||
}
|
||||
if (!chunk) {
|
||||
debugStreamRun(lcError, 'chunk 为空:', {chunk, chunkText});
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return [
|
||||
JSON.parse(chunk)
|
||||
];
|
||||
} catch (e) {
|
||||
hasFailChunk = true;
|
||||
debugStreamRun(lcError, 'chunk 解析失败:', {chunk, chunkText});
|
||||
console.error(e);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
if (hasFailChunk) {
|
||||
// 解析失败是因为数据量太大,一次性传输的 chunk 不完整,需要合并再解析
|
||||
// 记录解析失败的数据
|
||||
failChunkText += chunkText;
|
||||
}
|
||||
chunks.forEach(handleChunkData);
|
||||
}
|
||||
|
||||
const cbMap = new Map<RunEventType, Fn>();
|
||||
const chunkHandled = new Set<string>();
|
||||
|
||||
function handleChunkData(data: {
|
||||
data: Recordable,
|
||||
event: RunEventType,
|
||||
flowId?: string,
|
||||
requestId: string,
|
||||
success?: boolean,
|
||||
message?: string,
|
||||
}) {
|
||||
//update-begin---author:wangshuai---date:2025-03-25---for:【QQYUN-11724】调试流程时,如果直接失败,调试界面会卡主---
|
||||
if (data.success == false) {
|
||||
let cb = cbMap.get("FLOW_FINISHED");
|
||||
if (typeof cb === 'function') {
|
||||
cb(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-25---for:【QQYUN-11724】调试流程时,如果直接失败,调试界面会卡主---
|
||||
const key = `${data.event}-${data.data?.id || data.data?.fromNodeId || ''}`;
|
||||
if (chunkHandled.has(key)) {
|
||||
debugStreamRun(lcWarn, 'chunk 重复执行:', {key, data});
|
||||
return;
|
||||
}
|
||||
chunkHandled.add(key);
|
||||
const cb = cbMap.get(data.event);
|
||||
if (typeof cb === 'function') {
|
||||
debugStreamRun(lcInfo, ` ------ 处理 ${data.event} 事件:`, {key, data});
|
||||
cb(data.data, data);
|
||||
} else {
|
||||
debugStreamRun(lcWarn, `${data.event} 事件对应的回调不存在:`, {key, data});
|
||||
}
|
||||
}
|
||||
|
||||
function setCB(type: RunEventType, cb: Fn) {
|
||||
cbMap.set(type, cb);
|
||||
}
|
||||
|
||||
return {
|
||||
run: send,
|
||||
onFlowStarted: (cb: Fn) => setCB('FLOW_STARTED', cb),
|
||||
onFlowFinished: (cb: Fn) => setCB('FLOW_FINISHED', cb),
|
||||
onNodeStarted: (cb: Fn) => setCB('NODE_STARTED', cb),
|
||||
onNodeFinished: (cb: Fn) => setCB('NODE_FINISHED', cb),
|
||||
onMessage: (cb: Fn) => setCB('MESSAGE', cb),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProcessList(params?: any) {
|
||||
return defHttp.get({url: Api.list, params});
|
||||
}
|
||||
|
||||
export async function addProcess(data: Recordable, opt?: Recordable) {
|
||||
const silent = opt?.silent ?? false;
|
||||
return defHttp.post({url: Api.add, params: data}, {
|
||||
successMessageMode: silent ? 'none' : 'success',
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProcess(data: Recordable) {
|
||||
return defHttp.put({url: Api.edit, params: data});
|
||||
}
|
||||
|
||||
// 发布流程
|
||||
export async function releaseProcess(flowId: string, un = false) {
|
||||
const msg = un ? '取消发布' : '发布'
|
||||
const res = await defHttp.put({
|
||||
url: Api.edit, params: {
|
||||
id: flowId,
|
||||
status: un ? 'enable' : 'release',
|
||||
}
|
||||
}, {successMessageMode: 'none', isTransformResponse: false});
|
||||
if (res.success) {
|
||||
$message.success(`${msg}成功`)
|
||||
return true;
|
||||
}
|
||||
$message.warn(res.message || `${msg}失败`)
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function updateDesign(data: Recordable, opt?: Recordable) {
|
||||
const silent = opt?.silent ?? false;
|
||||
return defHttp.put({
|
||||
url: Api.designSave, params: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
chain: data.chain,
|
||||
design: data.design,
|
||||
}
|
||||
}, {
|
||||
successMessageMode: silent ? 'none' : 'success',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteProcess(id: string) {
|
||||
return defHttp.delete({
|
||||
url: Api.deleteById,
|
||||
data: {id}
|
||||
}, {
|
||||
joinParamsToUrl: true
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBatchProcess(idList: string[]) {
|
||||
return defHttp.delete({
|
||||
url: Api.deleteBatch,
|
||||
data: {
|
||||
ids: idList.join(',')
|
||||
}
|
||||
}, {
|
||||
joinParamsToUrl: true
|
||||
});
|
||||
}
|
||||
|
||||
export async function querySubflowList(
|
||||
params: Recordable,
|
||||
) {
|
||||
return defHttp.get({
|
||||
url: Api.subflowList,
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function querySubflowById(subflowId: string) {
|
||||
return defHttp.get({
|
||||
url: Api.querySubflowById,
|
||||
params: {subflowId}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const viewBox = "2 2 20 20";
|
||||
|
||||
export const AddIconSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="${viewBox}">
|
||||
<path fill="currentColor" d="M17 13h-4v4h-2v-4H7v-2h4V7h2v4h4m-5-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
export const CircleIconSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="${viewBox}">
|
||||
<path fill="currentColor" d="M12 20a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8m0-18A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2" />
|
||||
</svg>
|
||||
`;
|
||||
@@ -0,0 +1,604 @@
|
||||
<!-- 调试 -->
|
||||
<template>
|
||||
<BasicDrawer
|
||||
@register="registerDrawer"
|
||||
:width="600"
|
||||
title="调试"
|
||||
:mask="false"
|
||||
:getContainer="false"
|
||||
:closeFunc="closeFunc"
|
||||
@close="onClose"
|
||||
>
|
||||
<a-tabs v-if="getVisible" :activeKey="activeKey" animated @change="onTabChange">
|
||||
<a-tab-pane tab="输入" key="input">
|
||||
<a-spin :spinning="loading">
|
||||
<a-alert
|
||||
v-if="errorTip"
|
||||
message="错误"
|
||||
:description="errorTip"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
<template v-else>
|
||||
<template v-if="formSchemas.length">
|
||||
<a-alert type="info" show-icon>
|
||||
<template #message>
|
||||
<span>请填写开始节点中配置的参数</span>
|
||||
<a-divider type="vertical"/>
|
||||
<a-space class="gen-prompt-btn" @click="genTestData" :size="4">
|
||||
<Icon icon="mdi:star-four-points"/>
|
||||
<span>生成</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-alert>
|
||||
<DebugRunForm ref="formRef" :schemas="formSchemas"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-alert type="info" message="当前流程没有配置参数,可直接点击调试" show-icon/>
|
||||
</template>
|
||||
<div style="margin-top: 12px;">
|
||||
<a-button
|
||||
block
|
||||
size="large"
|
||||
type="primary"
|
||||
preIcon="codicon:debug-start"
|
||||
@click="onClickRun"
|
||||
>
|
||||
<span>开始调试</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane tab="详情" key="info">
|
||||
<div :class="['logs-box', runStore.status]">
|
||||
<div class="logs-bar">
|
||||
<div class="bar-item status">
|
||||
<div class="item-title">状态</div>
|
||||
<div class="item-content">
|
||||
<a-space v-if="runStore.isRunning">
|
||||
<Icon icon="eos-icons:bubble-loading" :size="14"/>
|
||||
<span>调试中</span>
|
||||
</a-space>
|
||||
<a-space v-else-if="runStore.isFailed">
|
||||
<Icon icon="ix:namur-failure-filled" :size="14"/>
|
||||
<span>调试失败</span>
|
||||
</a-space>
|
||||
<a-space v-else-if="runStore.isFinished">
|
||||
<Icon icon="ix:success" :size="14"/>
|
||||
<span>调试成功</span>
|
||||
</a-space>
|
||||
<span v-else>{{ runStore.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-item">
|
||||
<div class="item-title">调试时间</div>
|
||||
<div class="item-content">
|
||||
<span v-if="runStore.isFinished || runStore.isFailed">{{ runStore.timeText }}</span>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider-text">参数</div>
|
||||
<div class="params-bar">
|
||||
<div class="params-item input">
|
||||
<div class="title">输入</div>
|
||||
<div class="content">
|
||||
<pre>{{ runStore.inputParams }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="params-item output">
|
||||
<div class="title">输出</div>
|
||||
<div class="content">
|
||||
<span v-if="runStore.isRunning">-</span>
|
||||
<span v-else-if="runStore.isFailed">{{ runStore.resMessage }}</span>
|
||||
<pre v-else>{{ runStore.outputParams }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane tab="追踪" key="trace">
|
||||
<div :class="['logs-box', runStore.status]">
|
||||
<template v-if="runStore.nodeSteps.length">
|
||||
<div class="node-bar">
|
||||
<template v-for="step of runStore.nodeSteps">
|
||||
<div
|
||||
:class="['node-item', step.status, {expansion: step.expansion}]"
|
||||
@click="step.expansion = !step.expansion"
|
||||
>
|
||||
<div class="node-header">
|
||||
<a-space class="info">
|
||||
<div class="icon">
|
||||
<NodeIcon :type="step.node.type"/>
|
||||
</div>
|
||||
<span class="airag-node-label">{{ step.node.text }}</span>
|
||||
</a-space>
|
||||
<div class="time">
|
||||
<span v-if="step.status === 'running'">
|
||||
<Icon icon="eos-icons:bubble-loading" :size="14"/>
|
||||
</span>
|
||||
<span v-else>耗时:{{ step.timeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="params-bar">
|
||||
<div class="params-item input">
|
||||
<div class="title">输入</div>
|
||||
<div class="content">
|
||||
<pre>{{ step.inputParams }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="params-item output">
|
||||
<div class="title">输出</div>
|
||||
<div class="content">
|
||||
<span v-if="step.status === 'running'">-</span>
|
||||
<pre v-else>{{ step.outputParams }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane tab="结果" key="result">
|
||||
<div :class="['logs-box', runStore.status]">
|
||||
<div class="params-bar">
|
||||
<div class="params-item output">
|
||||
<div class="content">
|
||||
<span v-if="runStore.isRunning">-</span>
|
||||
<span v-else-if="runStore.isFailed">{{ runStore.resMessage }}</span>
|
||||
<pre v-else>{{ runStore.outputResult }}</pre>
|
||||
</div>
|
||||
<div v-if="!runStore.isFailed" style="margin-top: 8px;">
|
||||
<a-button preIcon="codicon:copy" size="small" ghost @click="onCopyResult" type="primary">复制</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</BasicDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {Ref} from 'vue'
|
||||
import {ref, inject, onUnmounted} from 'vue';
|
||||
import type {FormSchema} from "@/components/Form";
|
||||
import {LogicFlow} from "@logicflow/core";
|
||||
import {pick} from 'lodash-es';
|
||||
import {useMessage} from "@/hooks/web/useMessage";
|
||||
import {copyTextToClipboard} from '/@/hooks/web/useCopyToClipboard';
|
||||
import {BasicDrawer, useDrawerInner} from '/@/components/Drawer';
|
||||
import DebugRunForm from "./DebugRunForm.vue";
|
||||
import {createStreamRun} from '../api/api'
|
||||
import {useRunStore} from "../store/runStore";
|
||||
import NodeIcon from "./NodeIcon.vue";
|
||||
|
||||
defineProps({})
|
||||
const emit = defineEmits(['register']);
|
||||
const {createMessage: $message, createConfirm} = useMessage();
|
||||
// logicFlow 实例 NY5LzSY2VW1BSthYSnJArCFqbgwtZqSuyPQ/OD1n1twWJGU2RN/wkzf+kBVO5DztN85Ca9keeuaRAiwcatr8N0M15+Wv2SmRw82lMwawE2naX5tpJMpkxrUhUUcjnC+BSBL4+PV2JUXBFW8/oOG8HLqYvmPxoP7MMBhMi9D7lRY=
|
||||
const formRef = ref<InstanceType<typeof DebugRunForm>>();
|
||||
const lfRef = inject<Ref<LogicFlow | undefined>>('lfRef', ref<LogicFlow>());
|
||||
const doSubmit = inject<Fn>('doSubmit');
|
||||
|
||||
const runStore = useRunStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const errorTip = ref('');
|
||||
const params = ref<Recordable>({});
|
||||
const fields = ref<Recordable[]>([]);
|
||||
|
||||
type ActiveKeyType = 'input' | 'result' | 'info' | 'trace';
|
||||
const activeKey = ref<ActiveKeyType>('input');
|
||||
const onTabChange = (key: ActiveKeyType) => {
|
||||
if (loading.value) {
|
||||
$message.warn('正在调试请稍后……')
|
||||
return;
|
||||
}
|
||||
if (!runStore.isRunning && !runStore.isFinished) {
|
||||
$message.warn('请先调试流程')
|
||||
return;
|
||||
}
|
||||
activeKey.value = key;
|
||||
}
|
||||
|
||||
const formSchemas = ref<FormSchema[]>([]);
|
||||
|
||||
const [registerDrawer, {getVisible, closeDrawer}] = useDrawerInner(async (_data) => {
|
||||
errorTip.value = '';
|
||||
if (!lfRef.value) {
|
||||
errorTip.value = '尚未初始化';
|
||||
return
|
||||
}
|
||||
const startNode = lfRef.value.getNodeDataById('start-node');
|
||||
if (!startNode) {
|
||||
errorTip.value = '未找到开始节点';
|
||||
return
|
||||
}
|
||||
fields.value = []
|
||||
const inputParams = startNode.properties?.inputParams;
|
||||
if (Array.isArray(inputParams) && inputParams.length > 0) {
|
||||
params.value = {x: 1};
|
||||
formSchemas.value = inputParams.flatMap((item: Recordable) => {
|
||||
if (item.field === 'history') {
|
||||
return [] as FormSchema[];
|
||||
}
|
||||
if (item.type === 'picture') {
|
||||
return [createPictureSchema(item)] as FormSchema[];
|
||||
}
|
||||
|
||||
fields.value.push({field: item.field, name: item.name, type: item.type, required: item.required});
|
||||
if (item.type === 'string' || item.type === 'text') {
|
||||
return [createTextSchema(item)] as FormSchema[];
|
||||
}
|
||||
if (item.type === 'number') {
|
||||
return [createNumberSchema(item)] as FormSchema[];
|
||||
}
|
||||
return [createSlotSchema(item, 'unknown')] as FormSchema[];
|
||||
});
|
||||
} else {
|
||||
params.value = {};
|
||||
formSchemas.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
function createTextSchema(field: Recordable): FormSchema {
|
||||
return createSchema(field);
|
||||
}
|
||||
|
||||
function createNumberSchema(field: Recordable): FormSchema {
|
||||
return createSchema(field, {
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
style: {width: '180px'},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createPictureSchema(field: Recordable): FormSchema {
|
||||
return createSlotSchema(field, 'picture');
|
||||
}
|
||||
|
||||
function createSlotSchema(field: Recordable, slotName: string): FormSchema {
|
||||
return createSchema(field, {slot: slotName, required: false});
|
||||
}
|
||||
|
||||
function createSchema(field: Recordable, other: Partial<FormSchema> = {}): FormSchema {
|
||||
return {
|
||||
field: field.field,
|
||||
label: field.name,
|
||||
component: 'Input',
|
||||
required: field.required,
|
||||
...other,
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickRun() {
|
||||
let params: Recordable = {};
|
||||
if (formRef.value) {
|
||||
try {
|
||||
params = await formRef.value.validate();
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
runStore.start(params);
|
||||
setSilentMode(true);
|
||||
|
||||
if (doSubmit) {
|
||||
try {
|
||||
await doSubmit({
|
||||
silent: true,
|
||||
needName: false,
|
||||
saveFn(flowRecord: Recordable) {
|
||||
flowRecord = pick(flowRecord, ['design', 'chain']);
|
||||
doDebugRun(flowRecord, params);
|
||||
},
|
||||
onError: () => {
|
||||
endRun()
|
||||
loading.value = false;
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
endRun()
|
||||
console.error(e)
|
||||
loading.value = false;
|
||||
$message.error(`保存失败,请稍后重试`);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
} else {
|
||||
$message.error(`当前环境无法调试`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// async function doDebugRun(flowId: string, params: Recordable) {
|
||||
async function doDebugRun(flowRecord: Recordable, params: Recordable) {
|
||||
if (!lfRef.value) {
|
||||
errorTip.value = '尚未初始化';
|
||||
return
|
||||
}
|
||||
try {
|
||||
activeKey.value = 'trace';
|
||||
loading.value = true;
|
||||
console.debug('doDebugRun - params :', params)
|
||||
// 创建流式运行实例
|
||||
const StreamRun = createStreamRun(flowRecord, params);
|
||||
// 监听流程开始
|
||||
StreamRun.onFlowStarted((_data: Recordable) => {
|
||||
runStore.beginTime = Date.now();
|
||||
})
|
||||
// 监听流程结束
|
||||
StreamRun.onFlowFinished((data: Recordable) => {
|
||||
//update-begin---author:wangshuai---date:2025-03-25---for:【QQYUN-11724】调试流程时,如果直接失败,调试界面会卡主---
|
||||
if (!data.success && !data.outputs) {
|
||||
activeKey.value = 'result';
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-03-25---for:【QQYUN-11724】调试流程时,如果直接失败,调试界面会卡主---
|
||||
runStore.finish(data.success, data.message, data.outputs);
|
||||
})
|
||||
// 监听节点开始
|
||||
StreamRun.onNodeStarted((data: Recordable) => {
|
||||
runStore.addStep({
|
||||
node: {
|
||||
id: data.id,
|
||||
type: data.type,
|
||||
text: data.text,
|
||||
},
|
||||
status: 'running',
|
||||
inputParams: data.inputs,
|
||||
outputParams: data.outputs,
|
||||
})
|
||||
})
|
||||
// 监听节点结束
|
||||
StreamRun.onNodeFinished((data: Recordable) => {
|
||||
const status = data.success ? 'success' : 'fail';
|
||||
runStore.updateStepStatus(data.id, status, data.outputs);
|
||||
})
|
||||
StreamRun.onMessage((data: Recordable) => {
|
||||
runStore.addOutputText(data.message);
|
||||
});
|
||||
// 开始运行
|
||||
await StreamRun.run();
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeFunc() {
|
||||
return !runStore.isRunning;
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
if (runStore.isRunning) {
|
||||
$message.warn('正在调试请稍后……')
|
||||
return;
|
||||
}
|
||||
endRun();
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
endRun();
|
||||
})
|
||||
|
||||
function endRun() {
|
||||
activeKey.value = 'input';
|
||||
runStore.end();
|
||||
setSilentMode(false);
|
||||
}
|
||||
|
||||
function setSilentMode(silentMode: boolean) {
|
||||
if (!lfRef.value) {
|
||||
return
|
||||
}
|
||||
const graphModel = lfRef.value.graphModel;
|
||||
graphModel.$J.updateEditConfig({isSilentMode: silentMode});
|
||||
}
|
||||
|
||||
|
||||
function onCopyResult() {
|
||||
const text = typeof runStore.outputParams === 'string' ? runStore.outputParams : JSON.stringify(runStore.outputParams);
|
||||
let success = copyTextToClipboard(text);
|
||||
if (success) {
|
||||
$message.success('复制成功');
|
||||
} else {
|
||||
prompt('复制失败,请手动复制', text);
|
||||
}
|
||||
}
|
||||
|
||||
async function genTestData() {
|
||||
const confirmRes = createConfirm({
|
||||
title: '生成',
|
||||
iconType: 'info',
|
||||
content: '确定要生成测试数据吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
confirmRes.update({
|
||||
// 禁用取消按钮
|
||||
cancelButtonProps: {disabled: true},
|
||||
})
|
||||
return formRef.value?.genTestData?.(fields.value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
.gen-prompt-btn {
|
||||
cursor: pointer;
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.logs-box {
|
||||
|
||||
.logs-bar {
|
||||
width: 100%;
|
||||
background-color: #f0f0fa;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
padding: 18px;
|
||||
|
||||
.bar-item {
|
||||
width: 180px;
|
||||
height: 50px;
|
||||
|
||||
.item-title {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.status {
|
||||
.item-content {
|
||||
color: #67b7ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.divider-text {
|
||||
color: #999999;
|
||||
font-size: 14px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.params-bar {
|
||||
.params-item {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.node-bar {
|
||||
.node-item {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #67b7ff;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px #e6e6e6;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 8px #e6e6e6;
|
||||
}
|
||||
|
||||
&.success {
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
&.fail {
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.info {
|
||||
.icon {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.airag-node-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
|
||||
.params-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.expansion {
|
||||
.node-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.params-bar {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.finished {
|
||||
.logs-bar {
|
||||
background-color: #f0fae6;
|
||||
|
||||
.bar-item.status {
|
||||
.item-content {
|
||||
color: #4caf50;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.failed {
|
||||
.logs-bar {
|
||||
background-color: #fae6e6;
|
||||
|
||||
.bar-item.status {
|
||||
.item-content {
|
||||
color: #f44336;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!-- 调试运行参数表单 -->
|
||||
<template>
|
||||
<div style="margin-top: 12px;">
|
||||
<BasicForm @register="registerForm">
|
||||
<template #unKnown>
|
||||
<div>未知类型</div>
|
||||
</template>
|
||||
|
||||
<template #picture="{ model, field }">
|
||||
<j-image-upload v-model:value="model[field]" :fileMax="3" :uploadUrl="uploadUrl"/>
|
||||
</template>
|
||||
</BasicForm>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type {FormSchema} from "@/components/Form";
|
||||
import {defHttp} from "@/utils/http/axios";
|
||||
import {useGlobSetting} from "@/hooks/setting";
|
||||
import {useForm, BasicForm} from "@/components/Form";
|
||||
import JImageUpload from "@/components/Form/src/jeecg/components/JImageUpload.vue";
|
||||
|
||||
const props = defineProps({
|
||||
schemas: {
|
||||
type: Array as PropType<Recordable[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const { domainUrl } = useGlobSetting();
|
||||
|
||||
const uploadUrl = domainUrl + '/airag/chat/upload';
|
||||
|
||||
const [registerForm, formAction] = useForm({
|
||||
//注册表单列
|
||||
schemas: props.schemas as FormSchema[],
|
||||
labelCol: {span: 24},
|
||||
wrapperCol: {span: 24},
|
||||
showActionButtonGroup: false,
|
||||
});
|
||||
|
||||
// 生成测试数据
|
||||
async function genTestData(fields: Recordable[]) {
|
||||
const url = '/airag/flow/aigc/test-data';
|
||||
const params = {fields: fields};
|
||||
const res = await defHttp.post({
|
||||
url, params,
|
||||
timeout: 1000 * 60 * 5,
|
||||
})
|
||||
await formAction.resetFields();
|
||||
await formAction.setFieldsValue(res.data);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
...formAction,
|
||||
genTestData,
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!-- 节点图标 -->
|
||||
<template>
|
||||
<span v-if="unknown">-</span>
|
||||
<IconComponent v-else/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {NodeIconMap} from "../const";
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
})
|
||||
|
||||
const IconComponent = NodeIconMap.get(props.type)
|
||||
// 是否是未知类型
|
||||
const unknown = IconComponent == null
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<!-- 节点配置 -->
|
||||
<template>
|
||||
<div v-if="!reloading" :class="[prefixCls]">
|
||||
<a-empty v-if="unknown" description="暂无配置"/>
|
||||
<SettingComponent v-else v-bind="$props"/>
|
||||
</div>
|
||||
|
||||
<!-- 临时输入框 -->
|
||||
<div v-if="unknown" style="margin-top: 100px;">
|
||||
<a-textarea
|
||||
v-model:value="tempValue"
|
||||
style="width: 100%; height: 30vh;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, nextTick, computed} from 'vue';
|
||||
import {useDesign} from "@/hooks/web/useDesign";
|
||||
import {NodeSettingMap} from "../const";
|
||||
|
||||
const props = defineProps({
|
||||
type: {type: String, required: true},
|
||||
node: {type: Object, required: true},
|
||||
properties: {type: Object, required: true},
|
||||
setProperties: {type: Function, required: true},
|
||||
})
|
||||
const {prefixCls} = useDesign('airag-node-setting-container');
|
||||
|
||||
// 组件
|
||||
let SettingComponent = null;
|
||||
// 是否是未知类型
|
||||
const unknown = ref<boolean>(true)
|
||||
// 是否正在重新加载
|
||||
const reloading = ref<boolean>(false)
|
||||
|
||||
// 监听组件变化,重新加载组件
|
||||
watch(() => props.node?.id, async () => {
|
||||
reloading.value = true;
|
||||
|
||||
SettingComponent = props.type ? NodeSettingMap.get(props.type) : null;
|
||||
unknown.value = SettingComponent == null;
|
||||
|
||||
await nextTick()
|
||||
reloading.value = false;
|
||||
}, {immediate: true})
|
||||
|
||||
|
||||
const tempValue = computed({
|
||||
get: () => {
|
||||
if (!props.properties) {
|
||||
return ''
|
||||
}
|
||||
const properties = {...props.properties};
|
||||
delete properties.width;
|
||||
delete properties.height;
|
||||
return JSON.stringify(properties, null, 2)
|
||||
},
|
||||
set: (val: string) => {
|
||||
const properties = JSON.parse(val);
|
||||
properties.width = props.properties.width;
|
||||
properties.height = props.properties.height;
|
||||
props.setProperties(properties);
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
//noinspection LessUnresolvedVariable
|
||||
@prefix-cls: ~'@{namespace}-airag-node-setting-container';
|
||||
|
||||
.@{prefix-cls} {
|
||||
.setting-item {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
|
||||
&.flex-space-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.p-tip {
|
||||
color: #aaaaaa;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<!-- 节点配置弹窗 -->
|
||||
<template>
|
||||
<!-- 让抽屉的顶部和底部留有空白,并且四周为圆角的样式 -->
|
||||
<!-- rootClassName="airag-node-setting-drawer" -->
|
||||
<!-- :rootStyle="{top: '64px', bottom: '12px', right: '12px'}" -->
|
||||
<BasicDrawer
|
||||
@register="registerDrawer"
|
||||
:width="600"
|
||||
:mask="false"
|
||||
:getContainer="false"
|
||||
@close="onClose"
|
||||
>
|
||||
<template #title>
|
||||
<TitleEditor v-bind="titleEditorProps"/>
|
||||
</template>
|
||||
|
||||
<template v-if="getVisible">
|
||||
<template v-if="nodeCfg?.type">
|
||||
<a-space style="margin-bottom: 8px;">
|
||||
<NodeIcon :type="nodeCfg.type"/>
|
||||
<span class="airag-node-label">{{ nodeCfg.label }}</span>
|
||||
<span v-if="nodeCfg.docs">
|
||||
<a-tooltip title="查看文档">
|
||||
<a :href="nodeCfg.docs as string" target="_blank">
|
||||
<Icon icon="material-symbols:menu-book-outline-rounded" color="#666"/>
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</a-space>
|
||||
|
||||
<div class="remarks">
|
||||
<a-input v-model:value="nodeRemarks" placeholder="描述" style="width: 100%;"/>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<NodeSetting
|
||||
:type="nodeCfg.type"
|
||||
:node="nodeRef!"
|
||||
:properties="properties"
|
||||
:setProperties="setProperties"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-empty description="未知节点类型"/>
|
||||
</template>
|
||||
</template>
|
||||
</BasicDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {NodeConfig} from "../types";
|
||||
import {ref, unref, computed} from 'vue';
|
||||
import {cloneDeep} from 'lodash-es';
|
||||
import {BasicDrawer, useDrawerInner} from '/@/components/Drawer';
|
||||
import {usePropStore} from '../store/propStore'
|
||||
import {NodeConfigMap} from "../const";
|
||||
import NodeIcon from "./NodeIcon.vue";
|
||||
import TitleEditor from "./TitleEditor.vue";
|
||||
import NodeSetting from "./NodeSetting.vue";
|
||||
|
||||
defineProps({})
|
||||
const emit = defineEmits(['register', 'update']);
|
||||
|
||||
const propStore = usePropStore();
|
||||
|
||||
const nodeRef = ref<Recordable>();
|
||||
|
||||
// 节点属性
|
||||
const properties = computed<Recordable>({
|
||||
get: () => propStore.getProps(nodeRef.value?.id),
|
||||
set: (val) => propStore.updateProps(nodeRef.value?.id, val),
|
||||
});
|
||||
|
||||
const [registerDrawer, {getVisible, closeDrawer}] = useDrawerInner(async (data) => {
|
||||
nodeRef.value = data.node
|
||||
properties.value = cloneDeep(data.node.properties)
|
||||
});
|
||||
|
||||
const setProperties = (obj: Recordable) => {
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
properties.value[key] = value;
|
||||
});
|
||||
nodeRef.value!.properties = cloneDeep(properties.value);
|
||||
emit('update', nodeRef.value);
|
||||
};
|
||||
|
||||
const nodeText = computed({
|
||||
get: () => unref(properties).text,
|
||||
set: (text: string) => setProperties({text}),
|
||||
});
|
||||
|
||||
const nodeRemarks = computed({
|
||||
get: () => unref(properties).remarks,
|
||||
set: (remarks: string) => setProperties({remarks}),
|
||||
});
|
||||
|
||||
const nodeCfg = computed<NodeConfig>(() => {
|
||||
const type = nodeRef.value?.type;
|
||||
if (!type) {
|
||||
return {} as NodeConfig
|
||||
}
|
||||
const nodeConfig = NodeConfigMap.get(type);
|
||||
if (!nodeConfig) {
|
||||
return {} as NodeConfig
|
||||
}
|
||||
return nodeConfig;
|
||||
});
|
||||
|
||||
const titleEditorProps = computed(() => {
|
||||
return {
|
||||
title: nodeText.value,
|
||||
'onUpdate:title': (value: string) => nodeText.value = value,
|
||||
promptProps: {
|
||||
title: '修改节点名称',
|
||||
defaultValue: nodeText.value,
|
||||
placeholder: '请输入节点名称',
|
||||
rules: [
|
||||
{required: true, message: '请输入节点名称!'},
|
||||
{max: 12, message: '节点名称不能超过12个字符!'},
|
||||
],
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
function onClose() {
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.airag-node-setting-drawer {
|
||||
> .ant-drawer-content-wrapper {
|
||||
&, > .ant-drawer-content {
|
||||
//border-radius: 12px 0 0 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="less">
|
||||
.remarks {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.ant-input {
|
||||
border-color: transparent;
|
||||
background-color: #f9f9f9;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<!-- 弹窗标题编辑组件 -->
|
||||
<template>
|
||||
<div :class="[prefixCls]">
|
||||
<div class="text-area" @click="onClick">
|
||||
<span>{{ title }}</span>
|
||||
<span><Icon icon="ant-design:edit"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useJPrompt} from "@/components/jeecg/JPrompt/index";
|
||||
import {useDesign} from "@/hooks/web/useDesign";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
promptProps: {
|
||||
type: Object as PropType<Recordable>,
|
||||
default: () => ({}),
|
||||
},
|
||||
handleCustomEdit: {
|
||||
type: Function as PropType<Fn>,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['update:title']);
|
||||
|
||||
const {createJPrompt} = useJPrompt();
|
||||
const {prefixCls} = useDesign('modal-title-editor');
|
||||
|
||||
function onClick() {
|
||||
if (typeof props.handleCustomEdit === 'function') {
|
||||
props.handleCustomEdit();
|
||||
} else {
|
||||
showPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
function showPrompt(okCallback?: (value: string) => Promise<any>, cancelCallback?: () => any) {
|
||||
const {promptProps} = props;
|
||||
const isRequired = promptProps?.required ?? false;
|
||||
const rules = promptProps?.rules || [];
|
||||
if (isRequired) {
|
||||
rules.push({required: true, message: '这里是必填的'});
|
||||
}
|
||||
createJPrompt({
|
||||
title: promptProps?.title || '修改标题',
|
||||
defaultValue: promptProps?.defaultValue ?? props.title,
|
||||
placeholder: promptProps?.placeholder || '请输入新标题',
|
||||
rules: rules,
|
||||
async onOk(value) {
|
||||
emit('update:title', value);
|
||||
okCallback && (await okCallback(value));
|
||||
},
|
||||
onCancel() {
|
||||
cancelCallback && cancelCallback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showPrompt,
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
//noinspection LessUnresolvedVariable
|
||||
@prefix-cls: ~'@{namespace}-modal-title-editor';
|
||||
|
||||
.@{prefix-cls} {
|
||||
.text-area {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
transition: background-color 0.3s;
|
||||
padding: 3px 2px 3px 8px;
|
||||
|
||||
span:first-child {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div :class="`${prefixCls}-container`">
|
||||
<div class="table-header">
|
||||
<div class="table-row">
|
||||
<template v-for="column in columns" :key="column.field">
|
||||
<div class="table-cell">{{ column.label }}</div>
|
||||
</template>
|
||||
<!-- 操作 -->
|
||||
<div class="table-cell action"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-body">
|
||||
<template v-for="(row, index) in innerData" :key="row.field">
|
||||
<div class="table-row">
|
||||
<template v-for="column in columns" :key="column.field">
|
||||
<div class="table-cell">
|
||||
<template v-if="column.type === 'input'">
|
||||
<a-input v-model:value="row[column.field]" :placeholder="`请输入${column.label}`"/>
|
||||
</template>
|
||||
<template v-if="column.type === 'var-input'">
|
||||
<VarTextarea
|
||||
v-model:value="row[column.field]"
|
||||
:width="260"
|
||||
type="input"
|
||||
:placeholder="`${column.label},按下 “/” 可以选择变量`"
|
||||
:varsOptions="varsOptions"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="table-cell action" @click="removeRow(index)">
|
||||
<Icon icon="ic:round-delete"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="table-footer">
|
||||
<a @click="addRow">
|
||||
<Icon icon="ic:round-add"/>
|
||||
<span>添加参数</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, onMounted} from 'vue';
|
||||
import {useDesign} from '@/hooks/web/useDesign';
|
||||
import {debounce, cloneDeep} from 'lodash-es';
|
||||
import VarTextarea from './VarTextarea.vue';
|
||||
|
||||
type Column = {
|
||||
field: string;
|
||||
label: string;
|
||||
type: 'input' | 'var-input';
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
const {prefixCls} = useDesign('airag-var-editable');
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array as PropType<Column[]>,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array as PropType<Recordable[]>,
|
||||
required: true,
|
||||
},
|
||||
varsOptions: {
|
||||
type: Array as PropType<{ name: string; type: string }[]>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['update:data']);
|
||||
|
||||
const innerData = ref<Recordable[]>([]);
|
||||
watch(innerData, debounce(emitUpdate, 500), {deep: true});
|
||||
|
||||
onMounted(() => {
|
||||
innerData.value = cloneDeep(props.data);
|
||||
// 如果数据为空,则添加一行
|
||||
if (innerData.value.length === 0) {
|
||||
addRow();
|
||||
}
|
||||
});
|
||||
|
||||
function addRow() {
|
||||
const newRow = {};
|
||||
for (const column of props.columns) {
|
||||
newRow[column.field] = '';
|
||||
}
|
||||
innerData.value.push(newRow);
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
innerData.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// 更新数据,去除空值
|
||||
function emitUpdate() {
|
||||
const newData: Recordable[] = [];
|
||||
for1: for (const row of innerData.value) {
|
||||
for (const column of props.columns) {
|
||||
if (column.required && !row[column.field]) {
|
||||
continue for1;
|
||||
}
|
||||
}
|
||||
newData.push(row);
|
||||
}
|
||||
const newDataStr = JSON.stringify(newData);
|
||||
// 如果新数据和旧数据相同,则不更新
|
||||
if (newDataStr === JSON.stringify(props.data)) {
|
||||
return;
|
||||
}
|
||||
emit('update:data', JSON.parse(newDataStr));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
//noinspection LessUnresolvedVariable
|
||||
@prefix-cls: ~'@{namespace}-airag-var-editable';
|
||||
|
||||
.@{prefix-cls} {
|
||||
&-container {
|
||||
width: 100%;
|
||||
|
||||
.table-header,
|
||||
.table-body {
|
||||
.table-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.table-cell {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.action {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-header {
|
||||
margin-bottom: 6px;
|
||||
|
||||
.table-row {
|
||||
.table-cell {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-body {
|
||||
.table-row {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
&.action {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
color: #999999;
|
||||
background-color: #fff;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #333333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,356 @@
|
||||
<!-- 变量列表定义 -->
|
||||
<template>
|
||||
<a-empty
|
||||
v-if="isStartNode && vars.length === 0"
|
||||
:image-style="{height:'64px'}"
|
||||
:description="`尚未配置${$h.varName}`"
|
||||
/>
|
||||
<template v-for="(item, idx) of vars">
|
||||
<div :class="['field-item', {
|
||||
'field-item-fixed': isFixedVar(item.field),
|
||||
'field-item-allow-edit-name': isFixedVar(item.field) && isAllowEditName(item.field),
|
||||
}]" @click="onEditField(item, idx)">
|
||||
<div class="icon">
|
||||
<Icon :icon="getIconType(item.type)"/>
|
||||
</div>
|
||||
<div class="name">
|
||||
<span>{{ item.field }}</span>
|
||||
<a-divider type="vertical"/>
|
||||
<span style="color: #999999;">
|
||||
<span v-if="isFixedVar(item.field)">{{ fixedVars[item.field].tip || item.name }}</span>
|
||||
<span v-else>{{ item.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!isSubflowNode" class="action">
|
||||
<span v-if="isStartNode && item.required">必填</span>
|
||||
<span @click.stop="onDeleteVar(idx)">
|
||||
<Icon icon="ant-design:delete"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!isSubflowNode" class="field-add" style="width: 100%; margin-bottom:10px">
|
||||
<a type="text" style="border-radius: 15px" @click.stop="onAddField">
|
||||
<PlusOutlined/>
|
||||
<span style="margin-left: 6px;">添加{{ $h.varName }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<BasicModal @register="registerModal" :title="modalTitle" okText="保存" forceRender @ok="onSaveField">
|
||||
<div style="padding: 20px;">
|
||||
<BasicForm @register="registerForm"/>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, reactive} from 'vue'
|
||||
import {omit} from 'lodash-es';
|
||||
import {PlusOutlined} from '@ant-design/icons-vue';
|
||||
import {BasicModal, useModal} from "@/components/Modal";
|
||||
import {BasicForm, useForm} from "@/components/Form";
|
||||
import {useMessage} from "@/hooks/web/useMessage";
|
||||
import {checkVariableName} from "../../rules";
|
||||
|
||||
const {createConfirmSync} = useMessage();
|
||||
const props = defineProps({
|
||||
vars: {
|
||||
type: Array as PropType<Recordable[]>,
|
||||
required: true
|
||||
},
|
||||
fixedVars: {
|
||||
type: Object as PropType<Recordable>,
|
||||
default: () => ({})
|
||||
},
|
||||
// 参数选择的类型
|
||||
// start = 开始节点,需要必填
|
||||
// subflow = 子流程节点,需要必填
|
||||
// other = 其他节点,不需要必填,并且有更多的参数类型
|
||||
type: {
|
||||
type: String as PropType<'start' | 'subflow' | 'other'>,
|
||||
default: 'other',
|
||||
},
|
||||
fieldBeforeText: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:vars'])
|
||||
|
||||
const isStartNode = computed(() => props.type === 'start')
|
||||
const isSubflowNode = computed(() => props.type === 'subflow')
|
||||
|
||||
const $h = reactive((() => {
|
||||
return isStartNode.value ? {
|
||||
varName: '字段',
|
||||
} : {
|
||||
varName: '变量',
|
||||
};
|
||||
})());
|
||||
|
||||
const [registerModal, {openModal, closeModal}] = useModal();
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const modalTitle = computed(() => isUpdate.value ? `编辑${$h.varName}` : `添加${$h.varName}`);
|
||||
|
||||
// 要禁用的字段
|
||||
const disabledFields = ref<string[]>([]);
|
||||
|
||||
const [registerForm, formActions] = useForm({
|
||||
showActionButtonGroup: false,
|
||||
schemas: [
|
||||
{field: 'idx', label: '', component: 'InputNumber', show: false},
|
||||
{
|
||||
field: 'field',
|
||||
label: `${$h.varName}名称`,
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
addonBefore: props.fieldBeforeText,
|
||||
},
|
||||
dynamicDisabled: () => disabledFields.value.includes('field') || isSubflowNode.value,
|
||||
rules: [
|
||||
{required: true, message: `请输入${$h.varName}名称`},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const ret = checkVariableName(value, {
|
||||
allowDot: !!props.fieldBeforeText,
|
||||
});
|
||||
if (ret.passed) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return Promise.reject(`${$h.varName}名称` + ret.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
label: '显示名称',
|
||||
component: 'Input',
|
||||
dynamicDisabled: () => disabledFields.value.includes('name'),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
label: `${$h.varName}类型`,
|
||||
component: isStartNode.value ? 'RadioGroup' : 'Select',
|
||||
dynamicDisabled: () => disabledFields.value.includes('type'),
|
||||
componentProps: {
|
||||
options: getFieldTypeOptions(),
|
||||
getPopupContainer: () => document.body,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'required',
|
||||
label: '是否必填',
|
||||
component: 'Switch',
|
||||
dynamicDisabled: () => disabledFields.value.includes('required'),
|
||||
ifShow: () => isStartNode.value,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 根据节点类型获取 var 类型
|
||||
function getFieldTypeOptions() {
|
||||
const opts: Recordable[] = [
|
||||
{label: '文本', value: 'string'},
|
||||
{label: '数字', value: 'number'},
|
||||
];
|
||||
if (isStartNode.value) {
|
||||
opts.push(...[
|
||||
{label: '图片', value: 'picture'},
|
||||
]);
|
||||
} else {
|
||||
opts.push(...[
|
||||
{label: '对象', value: 'object'},
|
||||
{label: '文本数组', value: 'string[]'},
|
||||
{label: '数字数组', value: 'number[]'},
|
||||
{label: '对象数组', value: 'object[]'},
|
||||
]);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function getIconType(type: string) {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return 'memory:format-text'
|
||||
case 'number':
|
||||
return 'ant-design:number'
|
||||
case 'picture':
|
||||
return 'ant-design:picture'
|
||||
case 'object':
|
||||
return 'ic:baseline-data-object'
|
||||
case 'string[]':
|
||||
return 'carbon:array-strings'
|
||||
case 'number[]':
|
||||
return 'carbon:array-numbers'
|
||||
case 'object[]':
|
||||
return 'carbon:array-objects'
|
||||
}
|
||||
return 'memory:format-text'
|
||||
}
|
||||
|
||||
function isFixedVar(field: string) {
|
||||
return !!props.fixedVars[field];
|
||||
}
|
||||
|
||||
function isAllowEditName(field: string) {
|
||||
return isFixedVar(field) && (props.fixedVars[field]?.allowEditName ?? false);
|
||||
}
|
||||
|
||||
async function onDeleteVar(idx: number) {
|
||||
const flag = await createConfirmSync({title: '删除', content: `确定要删除这个${$h.varName}吗?`});
|
||||
if (!flag) {
|
||||
return;
|
||||
}
|
||||
const {vars} = props
|
||||
const newVars = [...vars];
|
||||
newVars.splice(idx, 1)
|
||||
emit('update:vars', newVars)
|
||||
}
|
||||
|
||||
async function onAddField() {
|
||||
isUpdate.value = false;
|
||||
disabledFields.value = [];
|
||||
openModal();
|
||||
await formActions.resetFields();
|
||||
await formActions.setFieldsValue({
|
||||
type: 'string',
|
||||
required: true,
|
||||
});
|
||||
await formActions.clearValidate();
|
||||
}
|
||||
|
||||
async function onEditField(record: Recordable, idx: number) {
|
||||
record = {...record}
|
||||
const {fieldBeforeText} = props
|
||||
if (fieldBeforeText && record.field.startsWith(fieldBeforeText)) {
|
||||
record.field = record.field.slice(fieldBeforeText.length);
|
||||
}
|
||||
const isFixed = isFixedVar(record.field);
|
||||
const isAllowEdit = isFixed && isAllowEditName(record.field);
|
||||
if (isFixed && !isAllowEdit) {
|
||||
return;
|
||||
}
|
||||
isUpdate.value = true;
|
||||
openModal();
|
||||
if (isFixed) {
|
||||
//update-begin---author:wangshuai---date:2025-03-31---for:【QQYUN-11793】流程,用户问题默认必填,可以取消必填---
|
||||
disabledFields.value = ['field', 'type'];
|
||||
//update-end---author:wangshuai---date:2025-03-31---for:【QQYUN-11793】流程,用户问题默认必填,可以取消必填---
|
||||
} else {
|
||||
disabledFields.value = [];
|
||||
}
|
||||
await formActions.resetFields();
|
||||
await formActions.setFieldsValue({
|
||||
...record,
|
||||
idx: idx,
|
||||
});
|
||||
await formActions.clearValidate();
|
||||
}
|
||||
|
||||
async function onSaveField() {
|
||||
try {
|
||||
const values = await formActions.validate();
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
const record: Recordable = {
|
||||
...omit(values, 'idx', 'required'),
|
||||
required: !!values.required,
|
||||
};
|
||||
const {vars, fieldBeforeText} = props
|
||||
|
||||
if (fieldBeforeText) {
|
||||
Object.entries(record).forEach(([key, value]) => {
|
||||
if (key === 'field' && value && !value.startsWith(fieldBeforeText)) {
|
||||
record.field = fieldBeforeText + value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const newVars = [...vars];
|
||||
newVars[values.idx] = values;
|
||||
if (isUpdate.value) {
|
||||
newVars[values.idx] = record
|
||||
} else {
|
||||
newVars.push(record)
|
||||
}
|
||||
emit('update:vars', newVars)
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.field-item {
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
background-color: #f9f9f9;
|
||||
padding: 8px 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
width: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
|
||||
> span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
|
||||
&.field-item-fixed {
|
||||
&:not(.field-item-allow-edit-name) {
|
||||
cursor: default;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
> span:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> span:last-child {
|
||||
display: inline-block;
|
||||
|
||||
&:hover {
|
||||
color: #ee0000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.field-item-fixed {
|
||||
.action {
|
||||
> span:first-child {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
> span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<!-- 变量列表选择 -->
|
||||
<template>
|
||||
<div>
|
||||
<template v-for="(item, idx) of innerVars" :key="idx">
|
||||
<div class="var-item">
|
||||
<div class="name">
|
||||
<a-input
|
||||
v-if="allowEditName"
|
||||
v-model:value="item.name"
|
||||
placeholder="请输入变量名"
|
||||
@blur="() => onNameBlur(item, idx, true)"
|
||||
@input="() => onNameBlur(item, idx, false)"
|
||||
/>
|
||||
<a-input v-else :value="item.name+(item.nameText?' / ' + item.nameText : '')" disabled/>
|
||||
<p v-if="errors[idx]?.name">
|
||||
<span>{{ errors[idx].name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<VarPicker :item="item" :vars="prevVariables" @change="(node) => updateItem(item, idx, node)"/>
|
||||
<p v-if="errors[idx]?.field">
|
||||
<span>{{ errors[idx].field }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="allowDelete" class="action" @click="onDeleteVar(idx)">
|
||||
<Icon icon="ant-design:delete"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="allowAdd" class="var-add" style="width: 100%; margin-bottom:10px">
|
||||
<a type="text" style="border-radius: 15px" @click="onAdd">
|
||||
<PlusOutlined/>
|
||||
<span style="margin-left: 6px;">添加变量</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref, computed} from 'vue'
|
||||
import {PlusOutlined} from '@ant-design/icons-vue';
|
||||
import VarPicker from "./VarPicker.vue";
|
||||
import {checkVariableName} from "../../rules";
|
||||
|
||||
const props = defineProps({
|
||||
vars: {
|
||||
type: Array as PropType<Recordable[]>,
|
||||
required: true
|
||||
},
|
||||
prevVariables: {
|
||||
type: Array as PropType<Recordable[]>,
|
||||
required: true
|
||||
},
|
||||
// 是否允许编辑name
|
||||
allowEditName: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否允许删除
|
||||
allowDelete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否允许添加
|
||||
allowAdd: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['update:vars'])
|
||||
|
||||
const innerVars = computed({
|
||||
get: () => [...props.vars],
|
||||
set: (val: Recordable[]) => {
|
||||
emit('update:vars', val)
|
||||
}
|
||||
})
|
||||
|
||||
function getVarValue(item: Recordable) {
|
||||
if (!item.nodeId || !item.field) {
|
||||
return ''
|
||||
}
|
||||
return item.nodeId + '.' + item.field
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
innerVars.value = [
|
||||
...innerVars.value,
|
||||
{field: '', name: '', nodeId: ''},
|
||||
]
|
||||
}
|
||||
|
||||
const errors = ref<{ name: string, field: string; }[]>([])
|
||||
|
||||
function setError(idx: number, key: string, value: string) {
|
||||
if (!errors.value[idx]) {
|
||||
errors.value[idx] = {name: '', field: ''}
|
||||
}
|
||||
errors.value[idx][key] = value;
|
||||
}
|
||||
|
||||
function onNameBlur(item: Recordable, idx: number, save: boolean) {
|
||||
if (!item.name) {
|
||||
setError(idx, 'name', '请输入变量名');
|
||||
} else {
|
||||
const ret = checkVariableName(item.name);
|
||||
if (ret.passed) {
|
||||
setError(idx, 'name', '');
|
||||
save && doSave()
|
||||
} else {
|
||||
setError(idx, 'name', ret.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateItem(item: Recordable, idx: number, node: Recordable) {
|
||||
if (!node?.nodeId) {
|
||||
item.nodeId = ''
|
||||
item.field = ''
|
||||
setError(idx, 'field', '请选择字段')
|
||||
} else {
|
||||
item.nodeId = node.nodeId
|
||||
item.field = node.field
|
||||
setError(idx, 'field', '')
|
||||
}
|
||||
doSave()
|
||||
}
|
||||
|
||||
function onDeleteVar(idx: number) {
|
||||
innerVars.value = innerVars.value.filter((_, i) => i !== idx)
|
||||
delete errors.value[idx]
|
||||
}
|
||||
|
||||
function doSave() {
|
||||
innerVars.value = [...innerVars.value]
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.var-item {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
align-items: flex-start;
|
||||
|
||||
> div {
|
||||
margin-right: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.name, &.field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.name {
|
||||
.ant-input-disabled {
|
||||
color: #333333 !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.action {
|
||||
margin-right: 0;
|
||||
text-align: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
> p {
|
||||
color: #ee0000;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<template v-for="item in vars">
|
||||
<p style="color: #666666;">
|
||||
{{ item.field }}<{{ item.type }}> {{ item.name }}
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
defineProps({
|
||||
vars: {
|
||||
type: Array as PropType<Recordable[]>,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<a-select
|
||||
:value="getValue"
|
||||
:options="options"
|
||||
placeholder="请选择字段"
|
||||
style="width: 100%;"
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
vars: {
|
||||
type: Array as PropType<Recordable[]>,
|
||||
required: true
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:value', 'change'])
|
||||
|
||||
const getValue = computed(() => {
|
||||
const {item} = props
|
||||
if (!item.nodeId || !item.field) {
|
||||
return ''
|
||||
}
|
||||
return item.nodeId + '.' + item.field
|
||||
});
|
||||
|
||||
const options = computed(() => {
|
||||
const opt = props.vars.map((item: Recordable) => {
|
||||
return {
|
||||
label: `${item.nodeName} / ${item.name}`,
|
||||
value: item.nodeId + '.' + item.field,
|
||||
origin: item
|
||||
}
|
||||
})
|
||||
opt.unshift({label: '请选择', value: '', origin: {}})
|
||||
return opt
|
||||
})
|
||||
|
||||
function onChange(value: string, option: { origin: Recordable }) {
|
||||
emit('update:value', value)
|
||||
const origin = option.origin
|
||||
if (!origin?.nodeId) {
|
||||
emit('change')
|
||||
} else {
|
||||
emit('change', origin)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<!-- 多行文本,可选择变量 -->
|
||||
<template>
|
||||
<div :class="[prefixCls, type, {focus: isFocus}]">
|
||||
<div v-if="showPlace" :class="[`${prefixCls}-place`]">
|
||||
<span>{{ placeholder }}</span>
|
||||
</div>
|
||||
<a-dropdown :open="showVarPicker">
|
||||
<template #overlay>
|
||||
<a-menu v-if="varsOptions.length" @click="onSelectVar">
|
||||
<template v-for="item in varsOptions" :key="item.name">
|
||||
<a-menu-item>
|
||||
<template #icon>
|
||||
<Icon icon="mdi:variable"/>
|
||||
</template>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<div>{{ item.name }}</div>
|
||||
<div style="color: #999999;">{{ item.type }}</div>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</a-menu>
|
||||
<a-empty v-else description="没有变量可选"/>
|
||||
</template>
|
||||
<div :class="[`${prefixCls}-border`]">
|
||||
<div
|
||||
ref="inputRef"
|
||||
:class="[`${prefixCls}-inner`]"
|
||||
contenteditable="true"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="onFocus"
|
||||
@keydown="onKeyDown"
|
||||
@paste="onPaste"
|
||||
/>
|
||||
<!-- 如果不是 input,则在右下角显示一个按钮,可以像 textarea 一样伸缩 -->
|
||||
<div v-if="!isInput" :class="[`${prefixCls}-resize`]" @mousedown="onMouseDownResize">
|
||||
<Icon icon="hugeicons:resize-field"/>
|
||||
</div>
|
||||
</div>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, watch} from 'vue';
|
||||
import {useDesign} from '@/hooks/web/useDesign';
|
||||
import {isEmpty} from '@/utils/is';
|
||||
|
||||
const {prefixCls} = useDesign('airag-var-textarea');
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// 输入框类型
|
||||
type: {
|
||||
type: String as PropType<'textarea' | 'input'>,
|
||||
default: 'textarea',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 输入框宽度,仅在 type 为 input 时有效
|
||||
width: {
|
||||
type: Number,
|
||||
default: 300,
|
||||
},
|
||||
// 输入框高度,仅在 type 为 textarea 时有效
|
||||
height: {
|
||||
type: Number,
|
||||
default: 80,
|
||||
},
|
||||
// 最小高度, 仅在 type 为 textarea 时有效
|
||||
minHeight: {
|
||||
type: Number,
|
||||
default: 32,
|
||||
},
|
||||
varsOptions: {
|
||||
type: Array as PropType<{ name: string, type: string }[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:value', 'blur', 'focus']);
|
||||
|
||||
const inputRef = ref<HTMLDivElement>();
|
||||
|
||||
const isInput = computed(() => props.type === 'input');
|
||||
|
||||
// 是否显示占位符
|
||||
const showPlace = computed(() => !isEmpty(props.placeholder) && isEmpty(props.value));
|
||||
// 输入框宽度
|
||||
const inputWidth = computed(() => {
|
||||
if (isInput.value) {
|
||||
return `${props.width}px`;
|
||||
}
|
||||
return '100%';
|
||||
});
|
||||
|
||||
const innerHeight = ref(props.height);
|
||||
watch(() => props.height, (val) => innerHeight.value = val);
|
||||
|
||||
// 文本框高度
|
||||
const boxHeight = computed(() => {
|
||||
if (isInput.value) {
|
||||
return '32px';
|
||||
}
|
||||
return `${innerHeight.value}px`;
|
||||
});
|
||||
|
||||
const innerValue = ref(props.value);
|
||||
const isChange = ref(true);
|
||||
const isFocus = ref(false);
|
||||
|
||||
watch(() => props.value, (val) => {
|
||||
if (val === '\n') {
|
||||
val = '';
|
||||
emit('update:value', val);
|
||||
return;
|
||||
}
|
||||
if (isChange.value) {
|
||||
setValue(val);
|
||||
}
|
||||
})
|
||||
|
||||
const showVarPicker = ref(false);
|
||||
|
||||
let inputLastSelection: Selection | null = null;
|
||||
let inputLastRange: Range | null = null;
|
||||
|
||||
function onInput(event: InputEvent) {
|
||||
showVarPicker.value = false;
|
||||
const char = event.data;
|
||||
// 如果是输入的 /,则显示变量选择框
|
||||
if (char === '/') {
|
||||
// 选中最新输入的 /,并记录当前选中的位置
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
const range = document.createRange();
|
||||
range.setStart(selection.anchorNode!, selection.anchorOffset - 1);
|
||||
range.setEnd(selection.anchorNode!, selection.anchorOffset);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
inputLastRange = range;
|
||||
inputLastSelection = selection;
|
||||
}
|
||||
showVarPicker.value = true;
|
||||
}
|
||||
const value = inputRef.value!.innerText;
|
||||
emit('update:value', value);
|
||||
}
|
||||
|
||||
function onBlur(event: Event) {
|
||||
isFocus.value = false;
|
||||
isChange.value = true;
|
||||
emit('blur', event);
|
||||
|
||||
setTimeout(() => {
|
||||
if (showVarPicker.value) {
|
||||
showVarPicker.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onFocus(event: Event) {
|
||||
isFocus.value = true;
|
||||
isChange.value = false;
|
||||
emit('focus', event);
|
||||
}
|
||||
|
||||
function setValue(value: string) {
|
||||
if (isInput.value) {
|
||||
innerValue.value = value.replace(/\n/g, '');
|
||||
} else {
|
||||
innerValue.value = value;
|
||||
}
|
||||
if (inputRef.value) {
|
||||
inputRef.value.innerText = innerValue.value;
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectVar({key: varName}) {
|
||||
varName = `{{${varName}}}`;
|
||||
inputRef.value!.focus();
|
||||
// 替换当前选中的位置的文本
|
||||
if (inputLastRange) {
|
||||
inputLastRange.deleteContents();
|
||||
inputLastRange.insertNode(document.createTextNode(varName));
|
||||
inputLastSelection?.removeAllRanges();
|
||||
inputLastSelection?.addRange(inputLastRange);
|
||||
// 取消选中,并且将光标移动到变量后面
|
||||
inputLastSelection?.collapseToEnd();
|
||||
} else {
|
||||
inputRef.value!.innerText += varName;
|
||||
}
|
||||
showVarPicker.value = false;
|
||||
if (isInput.value) {
|
||||
emit('update:value', inputRef.value!.innerText.replace(/\n/g, ''));
|
||||
} else {
|
||||
emit('update:value', inputRef.value!.innerText);
|
||||
}
|
||||
|
||||
inputLastRange = null;
|
||||
inputLastSelection = null;
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (isInput.value) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理粘贴事件,去除html标签,只能粘贴纯文本
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
const clipboardData = event.clipboardData || window['clipboardData'];
|
||||
if (!clipboardData) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
// 获取剪贴板中的纯文本
|
||||
const text = clipboardData.getData('text/plain');
|
||||
// 插入处理后的文本
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setValue(props.value);
|
||||
});
|
||||
|
||||
// 记录拖拽开始时的鼠标位置
|
||||
let startY = 0;
|
||||
|
||||
function onMouseDownResize(event: MouseEvent) {
|
||||
startY = event.clientY;
|
||||
inputRef.value!.style.pointerEvents = 'none';
|
||||
window.addEventListener('mousemove', onMouseMoveResize);
|
||||
window.addEventListener('mouseup', onMouseUpResize);
|
||||
}
|
||||
|
||||
function onMouseUpResize() {
|
||||
window.removeEventListener('mousemove', onMouseMoveResize);
|
||||
window.removeEventListener('mouseup', onMouseUpResize);
|
||||
inputRef.value!.style.pointerEvents = 'auto';
|
||||
}
|
||||
|
||||
function onMouseMoveResize(event: MouseEvent) {
|
||||
const deltaY = event.clientY - startY;
|
||||
const newHeight = innerHeight.value + deltaY;
|
||||
if (newHeight > props.minHeight) {
|
||||
innerHeight.value = newHeight;
|
||||
}
|
||||
startY = event.clientY;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
//noinspection LessUnresolvedVariable
|
||||
@prefix-cls: ~'@{namespace}-airag-var-textarea';
|
||||
|
||||
.@{prefix-cls} {
|
||||
width: v-bind(inputWidth);
|
||||
height: v-bind(boxHeight);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
&-place,
|
||||
&-border,
|
||||
&-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&-place,
|
||||
&-inner {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
&-place {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
color: #999999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&-border {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d9d9d9;
|
||||
transition: border-color 0.3s;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.focus &-border {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&-inner {
|
||||
cursor: text;
|
||||
color: #333333;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
// 设置纵向滚动条底部的偏移量
|
||||
&::-webkit-scrollbar-button {
|
||||
&:end {
|
||||
height: 5px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.input &-inner {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 4px solid #ffffff;
|
||||
border-left-width: 11px;
|
||||
border-right-width: 11px;
|
||||
border-radius: 4px;
|
||||
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
// 不允许文字换行
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.input &-place {
|
||||
padding: 4px 11px;
|
||||
}
|
||||
|
||||
&-resize {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
color: #d9d9d9;
|
||||
cursor: ns-resize;
|
||||
border: 1px solid #d9d9d9;
|
||||
background-color: #ffffff;
|
||||
|
||||
.app-iconify {
|
||||
display: block;
|
||||
transform: scale(0.8);
|
||||
transform-origin: left top;
|
||||
position: relative;
|
||||
left: -2.2px;
|
||||
top: -1.6px;
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
export {default as VarPicker} from './VarPicker.vue';
|
||||
export {default as VarListPicker} from './VarListPicker.vue';
|
||||
export {default as VarListEditor} from './VarListEditor.vue';
|
||||
export {default as VarListShow} from './VarListShow.vue';
|
||||
|
||||
export {default as VarTextarea} from './VarTextarea.vue';
|
||||
export {default as VarEditable} from './VarEditable.vue';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
import {NodeConfig} from "../types";
|
||||
|
||||
/**
|
||||
* 所有注册的节点配置
|
||||
*/
|
||||
export const NodeConfigMap = new Map<string, NodeConfig>()
|
||||
|
||||
/**
|
||||
* 所有节点图标
|
||||
*/
|
||||
export const NodeIconMap = new Map<string, any>()
|
||||
|
||||
/**
|
||||
* 所有节点配置
|
||||
*/
|
||||
export const NodeSettingMap = new Map<string, any>()
|
||||
|
||||
class NodeTypeOrderType extends Array<string> {
|
||||
static DIVIDER = 'divider';
|
||||
|
||||
clear() {
|
||||
this.splice(0, this.length);
|
||||
}
|
||||
|
||||
add(item: string) {
|
||||
this.push(item);
|
||||
}
|
||||
|
||||
addDivider() {
|
||||
this.push(NodeTypeOrderType.DIVIDER);
|
||||
}
|
||||
|
||||
isDivider(item: string) {
|
||||
return item === NodeTypeOrderType.DIVIDER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加节点列表的顺序
|
||||
*/
|
||||
export const NodeTypeOrder: NodeTypeOrderType = new NodeTypeOrderType();
|
||||
@@ -0,0 +1,68 @@
|
||||
import {unref} from 'vue';
|
||||
import {uniqBy} from 'lodash-es';
|
||||
import {GraphModel, LogicFlow} from '@logicflow/core';
|
||||
import {nodeDefWidth} from "../nodes/base-node/const";
|
||||
|
||||
export function useGraphUtils(lfRef: any, graphModel: GraphModel) {
|
||||
|
||||
const getLogicFlow = () => unref(lfRef) as LogicFlow | undefined;
|
||||
|
||||
/**
|
||||
* 使开始节点聚焦到画布中心,并向左偏移一定的距离
|
||||
*/
|
||||
function focusOnStartNode() {
|
||||
const lf = getLogicFlow();
|
||||
if (!lf) {
|
||||
return;
|
||||
}
|
||||
// 先将画布移动到开始节点
|
||||
lf.focusOn('start-node');
|
||||
// 再向左偏移一定的距离
|
||||
const width = lf.graphModel.width
|
||||
// 画布宽度的一半减去开始节点的一半再加上 20
|
||||
const offset = -(width / 2 - nodeDefWidth / 2) + 20;
|
||||
lf.translate(offset, 0);
|
||||
}
|
||||
|
||||
/** 重绘画布 */
|
||||
function repaintGraph() {
|
||||
// ※ 利用缩放来触发重绘
|
||||
graphModel.transformModel.zoom(true);
|
||||
graphModel.transformModel.zoom(false);
|
||||
}
|
||||
|
||||
/** 获取全部前置节点 */
|
||||
function getAllPrevNodes(node: Recordable): Recordable[] {
|
||||
const prevNodes: Recordable[] = [];
|
||||
// 递归查找前置节点
|
||||
const fn = (node: Recordable) => {
|
||||
// 1. 找到所有 targetNodeId 为当前节点 id 的连线
|
||||
const prevEdges = graphModel.edges.filter((edge) => edge.targetNodeId === node.id);
|
||||
if (prevEdges.length === 0) {
|
||||
return;
|
||||
}
|
||||
// 2. 根据找到的连线获取所有节点
|
||||
const nodes = prevEdges.flatMap((edge) => {
|
||||
const find = graphModel.nodes.find((node) => node.id === edge.sourceNodeId);
|
||||
if (!find) {
|
||||
return [];
|
||||
}
|
||||
return [find];
|
||||
});
|
||||
if (nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
prevNodes.push(...nodes);
|
||||
nodes.forEach((node) => fn(node));
|
||||
};
|
||||
fn(node);
|
||||
// 去重
|
||||
return uniqBy<Recordable>(prevNodes, 'id');
|
||||
}
|
||||
|
||||
return {
|
||||
focusOnStartNode,
|
||||
repaintGraph,
|
||||
getAllPrevNodes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type {Ref} from 'vue'
|
||||
import {ref, computed, inject} from 'vue';
|
||||
import type {KVItemType} from '../nodes/base-node/const'
|
||||
import LogicFlow from '@logicflow/core'
|
||||
import NodeContainer from "../nodes/base-node/NodeContainer.vue";
|
||||
import {usePropStore} from '../store/propStore'
|
||||
|
||||
export function useNode(props: { node: Recordable, graph: Recordable }, options?: {
|
||||
// 是否隐藏操作按钮
|
||||
hideAction?: boolean,
|
||||
// 更新节点事件
|
||||
onUpdateNode?: (node: Recordable) => void,
|
||||
}) {
|
||||
// logicFlow 实例
|
||||
const lfRef = inject<Ref<LogicFlow | undefined>>('lfRef', ref<LogicFlow>());
|
||||
// 容器Ref
|
||||
const containerRef = ref<InstanceType<typeof NodeContainer>>();
|
||||
// 容器属性
|
||||
const containerProps = computed(() => {
|
||||
return {...props, ...options}
|
||||
});
|
||||
|
||||
const propStore = usePropStore()
|
||||
|
||||
function updateHeight(height?: number) {
|
||||
containerRef.value?.updateHeight(height);
|
||||
}
|
||||
|
||||
const $node = computed<Recordable>(() => containerRef.value?.$node ?? {});
|
||||
const $properties = computed<Recordable>(() => propStore.getProps(props.node?.id));
|
||||
|
||||
// 全部前置节点
|
||||
const prevNodes = computed<Recordable[]>(getAllPrevNodes);
|
||||
|
||||
// 输入参数
|
||||
const inputParams = computed<Recordable[]>(() => {
|
||||
if (!Array.isArray($properties.value?.inputParams)) {
|
||||
return [] as Recordable[]
|
||||
}
|
||||
return $properties.value.inputParams as Recordable[]
|
||||
})
|
||||
|
||||
// 输出参数
|
||||
const outputParams = computed<Recordable[]>(() => {
|
||||
if (!Array.isArray($properties.value?.outputParams)) {
|
||||
return [] as Recordable[]
|
||||
}
|
||||
return $properties.value.outputParams as Recordable[]
|
||||
})
|
||||
|
||||
function getInputParamKVItem(opt: Recordable = {}) {
|
||||
const {
|
||||
filter = () => true,
|
||||
label = '输入参数',
|
||||
} = opt
|
||||
const inputParamsText = inputParams.value.filter(filter).map(({name}) => name).join(',')
|
||||
return {
|
||||
label,
|
||||
value: inputParamsText,
|
||||
emptyAction: opt.emptyAction ? opt.emptyAction : 'tip',
|
||||
emptyTip: '尚未设置',
|
||||
} as KVItemType
|
||||
}
|
||||
|
||||
function getOutputParamKVItem(opt: Recordable = {}) {
|
||||
const {
|
||||
filter = () => true,
|
||||
label = '输出参数',
|
||||
} = opt
|
||||
const outputParamsText = outputParams.value.filter(filter).map(({name}) => name).join(',')
|
||||
return {
|
||||
label,
|
||||
value: outputParamsText,
|
||||
emptyAction: opt.emptyAction ? opt.emptyAction : 'tip',
|
||||
emptyTip: '尚未设置',
|
||||
} as KVItemType
|
||||
}
|
||||
|
||||
// 生成store引用
|
||||
function createStoreRef<T>(key: string) {
|
||||
return propStore.createStoreRef<T>(props.node?.id, key);
|
||||
}
|
||||
|
||||
// 获取全部前置节点
|
||||
function getAllPrevNodes(): Recordable[] {
|
||||
if (!lfRef.value) {
|
||||
return [];
|
||||
}
|
||||
// const {node} = props;
|
||||
const {graphModel} = lfRef.value;
|
||||
return graphModel.$J.getAllPrevNodes(props.node);
|
||||
}
|
||||
|
||||
return {
|
||||
$node,
|
||||
$properties,
|
||||
updateHeight,
|
||||
|
||||
prevNodes,
|
||||
inputParams,
|
||||
outputParams,
|
||||
|
||||
containerRef,
|
||||
containerProps,
|
||||
|
||||
createStoreRef,
|
||||
|
||||
getInputParamKVItem,
|
||||
getOutputParamKVItem,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type {Ref} from 'vue'
|
||||
import {ref, computed, inject} from 'vue';
|
||||
import LogicFlow from '@logicflow/core'
|
||||
import {get, set, cloneDeep, unionBy} from 'lodash-es'
|
||||
import {NodeTypes} from "../types";
|
||||
import {usePropStore} from "../store/propStore";
|
||||
|
||||
export function useSettings(props: any) {
|
||||
// logicFlow 实例
|
||||
const lfRef = inject<Ref<LogicFlow | undefined>>('lfRef', ref<LogicFlow>());
|
||||
// 全部前置节点
|
||||
const prevNodes = computed<Recordable[]>(getAllPrevNodes);
|
||||
// 全部前置变量
|
||||
const prevVariables = computed<Recordable[]>(() => {
|
||||
if (prevNodes.value.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const vars: Recordable[] = [];
|
||||
for (const pNode of prevNodes.value) {
|
||||
let params = pNode.properties.outputParams
|
||||
// 开始节点特殊处理
|
||||
if (pNode.type === NodeTypes.START) {
|
||||
params = pNode.properties.inputParams
|
||||
}
|
||||
if (!Array.isArray(params) || params.length === 0) {
|
||||
continue;
|
||||
}
|
||||
vars.push(...params.map((param: Recordable) => ({
|
||||
nodeId: pNode.id,
|
||||
nodeName: pNode.properties.text,
|
||||
field: param.field,
|
||||
name: param.name,
|
||||
type: param.type,
|
||||
})));
|
||||
}
|
||||
return vars;
|
||||
});
|
||||
|
||||
// node选项
|
||||
const options = computed<Recordable>({
|
||||
get: () => {
|
||||
const {options} = props.properties
|
||||
return options as Recordable
|
||||
},
|
||||
set: (value: Recordable) => {
|
||||
props.setProperties({options: value})
|
||||
},
|
||||
})
|
||||
|
||||
// 输入参数
|
||||
const inputParams = computed<Recordable[]>({
|
||||
get: () => {
|
||||
const {inputParams} = props.properties
|
||||
if (!Array.isArray(inputParams)) {
|
||||
return [] as Recordable[]
|
||||
}
|
||||
return inputParams as Recordable[]
|
||||
},
|
||||
set: (value: Recordable[]) => {
|
||||
props.setProperties({inputParams: value})
|
||||
},
|
||||
})
|
||||
|
||||
// 输出参数
|
||||
const outputParams = computed<Recordable[]>({
|
||||
get: () => {
|
||||
const {outputParams} = props.properties
|
||||
if (!Array.isArray(outputParams)) {
|
||||
return [] as Recordable[]
|
||||
}
|
||||
return outputParams as Recordable[]
|
||||
},
|
||||
set: (value: Recordable[]) => {
|
||||
props.setProperties({outputParams: value})
|
||||
},
|
||||
})
|
||||
|
||||
const inputVarsOptions = computed(() => {
|
||||
return inputParams.value
|
||||
.filter(i => !!i.name && !!i.field)
|
||||
.map((param: Recordable) => generateVarOption(param));
|
||||
});
|
||||
|
||||
const outputVarsOptions = computed(() => {
|
||||
return outputParams.value
|
||||
.filter(i => !!i.name && !!i.field)
|
||||
.map((param: Recordable) => generateVarOption(param));
|
||||
});
|
||||
|
||||
function generateVarOption(param: Recordable) {
|
||||
// 获取type
|
||||
const type = prevVariables.value.find(v => v.nodeId === param.nodeId && v.field === param.field)?.type ?? '-';
|
||||
return {
|
||||
type,
|
||||
name: param.name,
|
||||
}
|
||||
}
|
||||
|
||||
// 获取全部前置节点
|
||||
function getAllPrevNodes(): Recordable[] {
|
||||
if (!lfRef.value) {
|
||||
return [];
|
||||
}
|
||||
// const {node} = props;
|
||||
const {graphModel} = lfRef.value;
|
||||
return graphModel.$J.getAllPrevNodes(props.node);
|
||||
}
|
||||
|
||||
function createOptionRef<T>(path: string) {
|
||||
return computed<T>({
|
||||
get: () => get(options.value, path) as T,
|
||||
set: (value: T) => updateOptions({[path]: value}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点选项
|
||||
*/
|
||||
function updateOptions(newOpt: Recordable) {
|
||||
const entries = Object.entries(newOpt);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const newOptions = cloneDeep(options.value);
|
||||
for (const [path, value] of Object.entries(newOpt)) {
|
||||
set(newOptions, path, value);
|
||||
}
|
||||
options.value = newOptions;
|
||||
}
|
||||
|
||||
const propStore = usePropStore()
|
||||
|
||||
// 生成store引用
|
||||
function createStoreRef<T>(key: string) {
|
||||
return propStore.createStoreRef<T>(props.node?.id, key);
|
||||
}
|
||||
|
||||
return {
|
||||
lfRef,
|
||||
prevNodes,
|
||||
prevVariables,
|
||||
|
||||
options,
|
||||
inputParams,
|
||||
outputParams,
|
||||
inputVarsOptions,
|
||||
outputVarsOptions,
|
||||
|
||||
updateOptions,
|
||||
createOptionRef,
|
||||
|
||||
createStoreRef,
|
||||
}
|
||||
}
|
||||
|
||||
const GET_DEFAULT = Symbol();
|
||||
|
||||
export function useUpdateSettings(node: Recordable, getDefProp: Fn) {
|
||||
let defProp: Nullable<Recordable> = null;
|
||||
|
||||
function getDefValue(key: string) {
|
||||
if (defProp == null) {
|
||||
defProp = getDefProp();
|
||||
}
|
||||
return get(defProp, key);
|
||||
}
|
||||
|
||||
function updateProp(key: string, value: Recordable | Symbol = GET_DEFAULT) {
|
||||
if (value === GET_DEFAULT) {
|
||||
value = getDefValue(key);
|
||||
}
|
||||
set(node.properties, key, value);
|
||||
}
|
||||
|
||||
function mergeIOParams(by: string = 'field') {
|
||||
let {inputParams, outputParams} = node.properties;
|
||||
if (!Array.isArray(inputParams)) {
|
||||
inputParams = [];
|
||||
}
|
||||
const defInputParams = getDefValue('inputParams');
|
||||
if (Array.isArray(defInputParams)) {
|
||||
inputParams = unionBy(inputParams, defInputParams, by);
|
||||
}
|
||||
updateProp('inputParams', inputParams);
|
||||
|
||||
if (!Array.isArray(outputParams)) {
|
||||
outputParams = [];
|
||||
}
|
||||
const defOutputParams = getDefValue('outputParams');
|
||||
if (Array.isArray(defOutputParams)) {
|
||||
outputParams = unionBy(outputParams, defOutputParams, by);
|
||||
}
|
||||
updateProp('outputParams', outputParams);
|
||||
}
|
||||
|
||||
return {
|
||||
updateProp,
|
||||
mergeIOParams,
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import ELNode from './type/ELNode';
|
||||
|
||||
export class ELStack {
|
||||
private stack: ELNode[] = [];
|
||||
private ebpStack: number[] = [];
|
||||
|
||||
|
||||
private peek(data: any[]) {
|
||||
return data[data.length - 1];
|
||||
}
|
||||
|
||||
public pop(): ELNode {
|
||||
return this.stack.pop()!;
|
||||
}
|
||||
|
||||
public push(item: ELNode) {
|
||||
this.stack.push(item);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.stack = [];
|
||||
}
|
||||
|
||||
public resolve(): ELNode {
|
||||
const ebp: number = this.peek(this.ebpStack);
|
||||
const typeNode: ELNode = this.stack[ebp + 1];
|
||||
|
||||
for (let i = ebp + 2; i < this.stack.length; i++) {
|
||||
const elNode: ELNode = this.stack[i];
|
||||
typeNode.addChild(elNode);
|
||||
}
|
||||
return typeNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 下一次分析的开始节点
|
||||
*/
|
||||
public quit() {
|
||||
const typeNode: ELNode = this.resolve();
|
||||
const endPoint: ELNode = this.stack[this.peek(this.ebpStack)];
|
||||
while (this.stack.length > this.peek(this.ebpStack)) {
|
||||
this.stack.pop();
|
||||
}
|
||||
this.stack.push(typeNode);
|
||||
this.ebpStack.pop();
|
||||
|
||||
return endPoint;
|
||||
}
|
||||
|
||||
public create() {
|
||||
this.ebpStack.push(this.stack.length);
|
||||
|
||||
//endpoint占位符
|
||||
this.stack.push(new ELNode());
|
||||
}
|
||||
|
||||
public addEndPoint(elNode: ELNode) {
|
||||
this.stack[this.peek(this.ebpStack)] = elNode;
|
||||
}
|
||||
|
||||
public getEndPoint(): ELNode {
|
||||
return this.stack[this.peek(this.ebpStack)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,377 @@
|
||||
import type {GraphConfigData, NodeConfig, AnchorNextIdsMap} from "./type";
|
||||
import ELNode from "./type/ELNode";
|
||||
import {ELStack} from "./ELStack";
|
||||
import {ELType, GroupType} from "./type/ELType";
|
||||
import MyParse from './MyParse';
|
||||
import {NodeTypes} from "../../types";
|
||||
import * as SwitchNodeUtils from "../../nodes/switch-node/utils";
|
||||
import * as ClassifierNodeUtils from "../../nodes/classifier-node/utils";
|
||||
|
||||
interface TextEntity {
|
||||
x: number,
|
||||
y: number,
|
||||
value: string,
|
||||
}
|
||||
|
||||
export default class MyContext {
|
||||
public endPoints: Record<string, ELNode[]> = {};
|
||||
public sourceNum: Record<string, number> = {};
|
||||
|
||||
private nodeMap: Record<string, ELNode> = {};
|
||||
|
||||
private inGrooupNode: Record<string, boolean> = {};
|
||||
|
||||
public elStack: ELStack = new ELStack();
|
||||
|
||||
public startId: string = "start"
|
||||
public endId: string = "end";
|
||||
|
||||
public anchorNextIdsMap: AnchorNextIdsMap;
|
||||
|
||||
constructor(anchorNextIdsMap: AnchorNextIdsMap) {
|
||||
this.anchorNextIdsMap = anchorNextIdsMap;
|
||||
}
|
||||
|
||||
public isEnd(id: string): boolean {
|
||||
return this.endId === id;
|
||||
}
|
||||
|
||||
public isStart(id: string): boolean {
|
||||
return this.startId === id;
|
||||
}
|
||||
|
||||
public getNodeById(id: string): ELNode {
|
||||
return this.nodeMap[id]
|
||||
}
|
||||
|
||||
private initEdge(sourceId: string, endId: string, edgeText: string = "") {
|
||||
// 循环内外的连接不处理
|
||||
if (this.inGrooupNode[sourceId] || this.inGrooupNode[endId]) return;
|
||||
|
||||
if (!this.endPoints[sourceId]) {
|
||||
this.endPoints[sourceId] = [];
|
||||
}
|
||||
const node = this.nodeMap[endId]
|
||||
node.comingEdgeText = edgeText;
|
||||
this.endPoints[sourceId].push(node);
|
||||
|
||||
if (!this.sourceNum[endId]) {
|
||||
this.sourceNum[endId] = 0;
|
||||
}
|
||||
this.sourceNum[endId]++;
|
||||
}
|
||||
|
||||
|
||||
public init(logicFlow: GraphConfigData) {
|
||||
const groupNode: NodeConfig[] = []
|
||||
//初始化所有节点并转化为ElNode
|
||||
logicFlow.nodes.forEach(node => {
|
||||
if (!node.id) return;
|
||||
if (node.type === ELType.GROUP) {
|
||||
groupNode.push(node);
|
||||
return
|
||||
}
|
||||
this.initELNode(node);
|
||||
})
|
||||
//最后处理所有的LOOP节点
|
||||
groupNode.forEach(node => {
|
||||
this.initLoopELNode(node);
|
||||
})
|
||||
|
||||
// 初始化start和end节点
|
||||
const start: ELNode = new ELNode();
|
||||
start.id = this.startId;
|
||||
this.nodeMap[this.startId] = start;
|
||||
|
||||
const end: ELNode = new ELNode();
|
||||
end.id = this.endId;
|
||||
this.nodeMap[this.endId] = end;
|
||||
|
||||
|
||||
//遍历边
|
||||
logicFlow.edges.forEach(edge => {
|
||||
const sourceId = edge.sourceNodeId
|
||||
const targetId = edge.targetNodeId
|
||||
if (!sourceId || !targetId) return;
|
||||
//if节点特殊处理
|
||||
if (this.nodeMap[sourceId].type === ELType.IF) this.parseIFEdge((edge.text as TextEntity)?.value, sourceId, targetId);
|
||||
else this.initEdge(sourceId, targetId, (edge.text as TextEntity)?.value);
|
||||
})
|
||||
|
||||
//检查节点,没有起点的连接到start,没有终点的连接到end
|
||||
logicFlow.nodes.forEach(node => {
|
||||
if (!node.id || this.inGrooupNode[node.id]) return;
|
||||
|
||||
if (!this.endPoints[node.id]) {
|
||||
this.initEdge(node.id, this.endId);
|
||||
}
|
||||
if (!this.sourceNum[node.id]) {
|
||||
this.initEdge(this.startId, node.id);
|
||||
}
|
||||
})
|
||||
|
||||
//没有节点时,只有start和end
|
||||
if (logicFlow.nodes.length === 0) {
|
||||
this.initEdge(this.startId, this.endId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证IF节点一定有两个分支,尽管可能有undefined
|
||||
* 且true分支在前,false分支在后
|
||||
* @param text
|
||||
* @param sourceId
|
||||
* @param targetId
|
||||
* @private
|
||||
*/
|
||||
private parseIFEdge(text: string, sourceId: string, targetId: string) {
|
||||
const target = this.nodeMap[targetId];
|
||||
let ends = this.endPoints[sourceId];
|
||||
//@ts-ignore
|
||||
if (!ends) ends = [undefined, undefined];
|
||||
if (this.isTrueText(text)) {
|
||||
if (ends[0]) ends[1] = ends[0];
|
||||
ends[0] = target;
|
||||
} else if (this.isFalseText(text)) {
|
||||
if (ends[1]) ends[0] = ends[1];
|
||||
ends[1] = target;
|
||||
} else {
|
||||
if (!ends[0]) ends[0] = target;
|
||||
else ends[1] = target;
|
||||
}
|
||||
this.endPoints[sourceId] = ends;
|
||||
|
||||
if (!this.sourceNum[targetId]) {
|
||||
this.sourceNum[targetId] = 0;
|
||||
}
|
||||
this.sourceNum[targetId]++;
|
||||
}
|
||||
|
||||
private isTrueText(text: string): boolean {
|
||||
const texts = ["是", "true", "True", "TRUE"];
|
||||
return !!texts.find(t => t === text);
|
||||
}
|
||||
|
||||
private isFalseText(text: string): boolean {
|
||||
const texts = ["否", "false", "False", "FALSE"];
|
||||
return !!texts.find(t => t === text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将logicFlow节点转化为ELNode
|
||||
*
|
||||
* @param lfNode Lf节点
|
||||
* @return {@link ELNode}
|
||||
*/
|
||||
private initELNode(lfNode: NodeConfig): ELNode {
|
||||
lfNode.properties = {...lfNode.properties};
|
||||
|
||||
lfNode.properties.nodeId = lfNode.id;
|
||||
lfNode.properties.name = lfNode.properties.text;
|
||||
lfNode.properties.tag = lfNode.id;
|
||||
|
||||
const node: ELNode = new ELNode();
|
||||
node.id = lfNode.id as string;
|
||||
node.type = this.typeFormat(lfNode.type);
|
||||
// 原始类型
|
||||
node.originType = lfNode.type;
|
||||
|
||||
// 处理switch节点
|
||||
this.handleSwitchNode(node, lfNode);
|
||||
|
||||
node.properties = lfNode.properties;
|
||||
|
||||
node.nodeId = lfNode.properties?.nodeId as string;
|
||||
node.name = lfNode.properties?.name as string;
|
||||
node.groupType = lfNode.properties?.groupType as GroupType;
|
||||
|
||||
node.data = lfNode.properties?.data as string;
|
||||
node.aliasId = lfNode.properties?.aliasId as string;
|
||||
node.tag = lfNode.properties?.tag as string;
|
||||
node.startNum = lfNode.properties?.startNum as number;
|
||||
|
||||
this.nodeMap[node.id] = node;
|
||||
return node;
|
||||
}
|
||||
|
||||
handleSwitchNode(node: ELNode, lfNode: NodeConfig) {
|
||||
if (node.type !== ELType.SWITCH) {
|
||||
return
|
||||
}
|
||||
const $utils = node.originType === NodeTypes.SWITCH ? SwitchNodeUtils : ClassifierNodeUtils;
|
||||
const caseList = $utils.getCaseList(lfNode);
|
||||
for (let i = 0; i < caseList.length; i++) {
|
||||
const caseItem = caseList[i];
|
||||
// const idx = node.originType === NodeTypes.CLASSIFIER ? i + 1 : i;
|
||||
const anchorId = $utils.getAnchorId(lfNode.id!, caseItem.type, i + 1);
|
||||
const nextIds = this.anchorNextIdsMap.get(anchorId);
|
||||
if (nextIds && nextIds.length > 0) {
|
||||
node.anchorsNextIds.push({anchorId, nextIds});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Group节点
|
||||
*
|
||||
* @param lfNode Lf节点
|
||||
*/
|
||||
private initLoopELNode(lfNode: NodeConfig) {
|
||||
const node: ELNode = this.initELNode(lfNode);
|
||||
if (node.groupType == GroupType.LOGIC) { //logic节点单独解析
|
||||
node.elString = this.getLogicStr(lfNode.flowData)
|
||||
} else {
|
||||
node.addChild(
|
||||
//@ts-ignore
|
||||
new MyParse(lfNode.flowData).parse()
|
||||
);
|
||||
}
|
||||
|
||||
this.nodeMap[node.id] = node;
|
||||
|
||||
// 标记内部节点不需要再次处理了
|
||||
//@ts-ignore
|
||||
lfNode.flowData.nodes.forEach(node => {
|
||||
const id = node.id as string;
|
||||
this.inGrooupNode[id] = true;
|
||||
});
|
||||
// 将连接到GROUP内部的边连接到GROUP
|
||||
//@ts-ignore
|
||||
lfNode.sourceNodeIds?.forEach(id => {
|
||||
this.initEdge(id, node.id)
|
||||
})
|
||||
//@ts-ignore
|
||||
lfNode.targetNodeIds?.forEach(id => {
|
||||
this.initEdge(node.id, id)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private typeFormat(lfType: string): ELType {
|
||||
// 处理SWITCH节点
|
||||
if (lfType === NodeTypes.SWITCH || lfType === NodeTypes.CLASSIFIER) {
|
||||
return ELType.SWITCH;
|
||||
}
|
||||
|
||||
if (lfType === "IF") return ELType.IF;
|
||||
if (lfType === "SWITCH") return ELType.SWITCH;
|
||||
if (lfType === "GROUP") return ELType.GROUP;
|
||||
if (lfType === "AND") return ELType.AND;
|
||||
if (lfType === "NOT") return ELType.NOT;
|
||||
if (lfType === "OR") return ELType.OR;
|
||||
return lfType as ELType;
|
||||
}
|
||||
|
||||
public setSourceNum(node: ELNode, num: number) {
|
||||
this.sourceNum[node.id] = num;
|
||||
}
|
||||
|
||||
public getSourceNum(node: ELNode) {
|
||||
const num = this.sourceNum[node.id]
|
||||
if (!num) return 0;
|
||||
return num;
|
||||
}
|
||||
|
||||
public getEndNum(node: ELNode): number {
|
||||
const elNodes = this.endPoints[node.id]
|
||||
if (!elNodes) return 0;
|
||||
return elNodes.length;
|
||||
}
|
||||
|
||||
public getEndList(node: ELNode): ELNode[] {
|
||||
let elNodes = this.endPoints[node.id];
|
||||
if (!elNodes) return [];
|
||||
return elNodes;
|
||||
}
|
||||
|
||||
public push(node: ELNode) {
|
||||
this.elStack.push(node);
|
||||
}
|
||||
|
||||
public pop(): ELNode {
|
||||
return this.elStack.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个类似函数的栈环境
|
||||
* @param node
|
||||
*/
|
||||
public createStackEnv(node: ELNode) {
|
||||
this.elStack.create();
|
||||
const newNode = new ELNode()
|
||||
newNode.id = node.id;
|
||||
newNode.type = node.type
|
||||
newNode.originType = node.originType
|
||||
newNode.groupType = node.groupType
|
||||
newNode.aliasId = node.aliasId
|
||||
newNode.name = node.name
|
||||
newNode.data = node.data
|
||||
newNode.tag = node.tag
|
||||
newNode.nodeId = node.nodeId
|
||||
newNode.child = node.child
|
||||
newNode.comingEdgeText = node.comingEdgeText
|
||||
newNode.elString = node.elString
|
||||
|
||||
newNode.anchorsNextIds = node.anchorsNextIds
|
||||
|
||||
//group专用
|
||||
newNode.startNode = node.startNode
|
||||
newNode.breakNode = node.breakNode
|
||||
newNode.exceptionNode = node.exceptionNode
|
||||
newNode.startNum = node.startNum
|
||||
this.elStack.push(newNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出当前栈空间
|
||||
* @returns 下次分析的开始节点
|
||||
*/
|
||||
public quitStackEnv() {
|
||||
return this.elStack.quit();
|
||||
}
|
||||
|
||||
public setStackEndPoint(node: ELNode) {
|
||||
this.elStack.addEndPoint(node);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param logicFlow logicGroup 的内部节点和边
|
||||
* @returns
|
||||
*/
|
||||
private getLogicStr(logicFlow: GraphConfigData): string {
|
||||
const nodeMap: Record<string, NodeConfig> = {}
|
||||
logicFlow.nodes.forEach(n => nodeMap[n.id!] = n)
|
||||
const targetNumMap = {} //每个节点的后继数量
|
||||
logicFlow.edges.forEach(e => {
|
||||
if (!targetNumMap[e.sourceNodeId]) {
|
||||
targetNumMap[e.sourceNodeId] = 1
|
||||
} else {
|
||||
targetNumMap[e.sourceNodeId]++
|
||||
}
|
||||
})
|
||||
// 查找后继数量为 0 的第一个节点 ID
|
||||
const firstZeroSuccessorNodeId = logicFlow.nodes.map(n => n.id!).find(id => !targetNumMap[id]);
|
||||
|
||||
const getStr = (id: string) => {
|
||||
const node = nodeMap[id];
|
||||
//所有source节点
|
||||
const startIds = logicFlow.edges.filter(e => e.targetNodeId === id).map(e => e.sourceNodeId);
|
||||
|
||||
const joins = startIds.map(id => {
|
||||
const n = nodeMap[id]
|
||||
if (n.type === ELType.ID) {
|
||||
return n.properties?.nodeId;
|
||||
}
|
||||
if (n.type === ELType.AND || n.type === ELType.OR || n.type === ELType.NOT) {
|
||||
return getStr(n.id!)
|
||||
}
|
||||
throw new Error("未知的节点类型")
|
||||
}).join(",")
|
||||
|
||||
return `${node.type}(${joins})`
|
||||
}
|
||||
|
||||
return getStr(firstZeroSuccessorNodeId!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import type {GraphConfigData, AnchorNextIdsMap} from "./type";
|
||||
import ELNode from "./type/ELNode";
|
||||
import MyContext from "./MyContext";
|
||||
import {ELType, GroupType} from "./type/ELType";
|
||||
|
||||
interface SingleNodeParseConfig {
|
||||
targetNode?: ELNode // 遇到这个节点就终止
|
||||
}
|
||||
|
||||
export default class MyParse {
|
||||
private readonly logicFlow: GraphConfigData;
|
||||
|
||||
private context: MyContext;
|
||||
|
||||
constructor(logicFlow: GraphConfigData, anchorNextIdsMap: AnchorNextIdsMap) {
|
||||
this.logicFlow = logicFlow;
|
||||
this.context = new MyContext(anchorNextIdsMap);
|
||||
this.context.init(this.logicFlow);
|
||||
}
|
||||
|
||||
public parse(): ELNode {
|
||||
const startId = this.context.startId;
|
||||
const node = new ELNode();
|
||||
node.id = startId;
|
||||
this.parseThenChain(node);
|
||||
return this.context.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一条链
|
||||
* @param node 这条链的开始节点
|
||||
* @param targetNode
|
||||
* @param defaultData 链前面的一些节点,这些节点是并行的
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
private parseThenChain(node: ELNode, targetNode?: ELNode, defaultData?: ELNode, id: string = ""): ELNode {
|
||||
this.context.createStackEnv({type: ELType.THEN, aliasId: id} as ELNode);
|
||||
|
||||
if (defaultData) {
|
||||
this.context.push(defaultData)
|
||||
}
|
||||
|
||||
this.parseSingleNode(node, {targetNode});
|
||||
return this.context.quitStackEnv();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析单个的then节点
|
||||
*
|
||||
* @param node 节点id
|
||||
* @param config
|
||||
* @returns
|
||||
*/
|
||||
private parseSingleNode(node: ELNode, config: SingleNodeParseConfig) {
|
||||
const id = node.id;
|
||||
// const inNum = this.context.getSourceNum(node);
|
||||
const outNum = this.context.getEndNum(node);
|
||||
const newConfig: SingleNodeParseConfig = {
|
||||
targetNode: config.targetNode
|
||||
}
|
||||
|
||||
if (this.context.isEnd(id)) {
|
||||
this.context.setStackEndPoint(node);
|
||||
return null;
|
||||
}
|
||||
if (config.targetNode && config.targetNode.id === node.id) {
|
||||
this.context.setStackEndPoint(node);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 虚拟节点不展示,其他节点入栈
|
||||
if (!this.context.isStart(id)) this.context.push(node);
|
||||
|
||||
//先分析特殊节点
|
||||
if (ELType.IF === node.type) {
|
||||
const next = this.parseIF(this.context.pop());
|
||||
return this.parseSingleNode(next, newConfig);
|
||||
}
|
||||
if (ELType.SWITCH === node.type) {
|
||||
const next = this.parseWhich(this.context.pop());
|
||||
return this.parseSingleNode(next, newConfig);
|
||||
}
|
||||
//需要考虑与或非表达式作为if起点的情况
|
||||
if (ELType.GROUP === node.type) {
|
||||
let next = this.parseGroup(this.context.pop());
|
||||
if (node.groupType === GroupType.LOGIC && outNum > 0 && !this.context.isEnd(this.context.getEndList(node)[0].id)) {
|
||||
const logicNode = this.context.pop();
|
||||
next = this.parseIF(logicNode);
|
||||
}
|
||||
return this.parseSingleNode(next, newConfig);
|
||||
}
|
||||
|
||||
//普通节点
|
||||
if (outNum === 1) {
|
||||
return this.parseSingleNode(this.context.getEndList(node)[0], newConfig);
|
||||
}
|
||||
//否则为when节点
|
||||
const next = this.parseWhenChain(node);
|
||||
return this.parseSingleNode(next, newConfig);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将各个分支依次压入栈中
|
||||
*
|
||||
* @param node 分支收束的节点
|
||||
*/
|
||||
private parseBranch(node: ELNode): ELNode {
|
||||
const follows = this.context.getEndList(node);
|
||||
|
||||
let next = this.getBranchEnd(node) as ELNode
|
||||
|
||||
follows.forEach(start => {
|
||||
if (start.id == next.id) { // 不解析空链
|
||||
return
|
||||
}
|
||||
this.parseThenChain(start, next, undefined, node.type === ELType.SWITCH ? start.comingEdgeText : "");
|
||||
//如果是WITCH节点,可以再边上添加id说明
|
||||
})
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
private parseIF(node: ELNode) {
|
||||
const typenode = new ELNode()
|
||||
typenode.type = ELType.IF;
|
||||
this.context.createStackEnv(typenode);
|
||||
|
||||
if (node.type === ELType.IF) node.type = ELType.ID;
|
||||
this.context.push(node);
|
||||
//if节点必然有两个分支,但是可能有undefined
|
||||
const ends = this.context.getEndList(node).filter(n => !!n);
|
||||
const outNum = ends.length;
|
||||
|
||||
// 当if节点分支数为1,必须在末尾
|
||||
if (outNum == 1) {
|
||||
let child: ELNode;
|
||||
let end: ELNode;
|
||||
try {
|
||||
child = ends[0];
|
||||
end = this.context.endPoints[child.id][0];
|
||||
if (!this.context.isEnd(end.id)) throw new Error();
|
||||
} catch (err) {
|
||||
throw new Error("IF 判断节点的分支数必须为2");
|
||||
}
|
||||
|
||||
this.context.push(child);
|
||||
return end;
|
||||
}
|
||||
if (outNum != 2) throw new Error("IF 判断节点的分支数必须为2");
|
||||
const end = this.parseBranch(node);
|
||||
this.context.setStackEndPoint(end);
|
||||
return this.context.quitStackEnv();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Switch节点,这里不应该使用parseBranch,每一个后继节点都应该看成单独的一条链
|
||||
* @param node type为Switch的节点
|
||||
* @returns
|
||||
*/
|
||||
private parseWhich(node: ELNode): ELNode {
|
||||
const follows = this.context.getEndList(node);
|
||||
const outNum = follows.length;
|
||||
if (outNum <= 1) throw new Error("WHICH 分支节点的分支数必须大于1");
|
||||
// 下面开始正式的解析
|
||||
this.context.createStackEnv(node);
|
||||
const next = this.parseBranch(node);
|
||||
this.context.setStackEndPoint(next);
|
||||
return this.context.quitStackEnv();
|
||||
}
|
||||
|
||||
/**
|
||||
* 不包含提前收束节点的并行解析方法
|
||||
* @param follows 所有开始节点
|
||||
* @param end 收束节点
|
||||
* @param datas
|
||||
* @returns
|
||||
*/
|
||||
private parseWhenBase(follows: ELNode[], end: ELNode, datas: ELNode[]): ELNode {
|
||||
this.context.createStackEnv({type: ELType.WHEN} as ELNode);
|
||||
follows.forEach((s, index) => {
|
||||
if (s.id == end.id) { // 不解析空链
|
||||
return
|
||||
}
|
||||
this.parseThenChain(s, end, datas[index]);
|
||||
})
|
||||
this.context.quitStackEnv();
|
||||
return this.context.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析When节点
|
||||
*
|
||||
* @param node when的开始节点
|
||||
* @returns when的结束节点,便于继续分析
|
||||
*/
|
||||
private parseWhenChain(node: ELNode): ELNode {
|
||||
this.context.createStackEnv({type: ELType.WHEN} as ELNode);
|
||||
const ends = this.context.getEndList(node)
|
||||
|
||||
const next = this.getBranchEnd(node) as ELNode
|
||||
const endNodes = this.getBranchEnd(node, next) as Record<string, number[]>
|
||||
|
||||
const endMap: Record<string, string> = {} //开始节点对应的收束节点
|
||||
const endData: Record<string, ELNode> = {} // 节点对应的数据
|
||||
|
||||
// 先将对象的键值对转换为数组并按照 number[] 的长度从小到大排序
|
||||
const sortedEntries = Object.entries(endNodes)
|
||||
.sort(([, a], [, b]) => a.length - b.length);
|
||||
|
||||
// 遍历排序后的数组
|
||||
sortedEntries.forEach(([endId, value]) => {
|
||||
const endNode = this.context.getNodeById(endId)
|
||||
if (value.length == 1) return // 长度为1说明不是收束节点
|
||||
|
||||
const follows: any[] = []
|
||||
value.forEach(index => {
|
||||
const node = ends[index]
|
||||
if (endMap[node.id]) {
|
||||
follows.push(endMap[node.id])
|
||||
} else {
|
||||
follows.push(node.id)
|
||||
}
|
||||
})
|
||||
value.forEach(index => {
|
||||
const node = ends[index]
|
||||
endMap[node.id] = endId
|
||||
})
|
||||
|
||||
const fs = Array.from(new Set(follows)).map(nodeId => this.context.getNodeById(nodeId))
|
||||
const data = this.parseWhenBase(fs, endNode, fs.map(f => endData[f.id]))
|
||||
endData[endId] = data
|
||||
|
||||
});
|
||||
|
||||
this.context.push(endData[next.id])
|
||||
|
||||
this.context.setStackEndPoint(next);
|
||||
return this.context.quitStackEnv();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析group
|
||||
* @param node
|
||||
*/
|
||||
private parseGroup(node: ELNode): ELNode {
|
||||
const ends = this.context.getEndList(node);
|
||||
|
||||
let res: ELNode;
|
||||
let toEnd: boolean = true;//是否直接连接到end,即没有后继节点
|
||||
|
||||
ends.forEach(end => {
|
||||
if (end.comingEdgeText) {
|
||||
this.parseThenChain(end);
|
||||
const then = this.context.elStack.pop();
|
||||
switch (end.comingEdgeText) {
|
||||
case "START":
|
||||
node.startNode = then.child[0];
|
||||
break;
|
||||
case "BREAK":
|
||||
node.breakNode = then.child[0];
|
||||
break;
|
||||
case "EXCEPTION":
|
||||
node.exceptionNode = then.child[0];
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
res = end;
|
||||
toEnd = false;
|
||||
}
|
||||
})
|
||||
this.context.elStack.push(node);
|
||||
if (toEnd) {
|
||||
return new ELNode(this.context.endId)
|
||||
}
|
||||
return res!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分支的结束节点 ,原理就是获取每个分支都能走到的节点
|
||||
* 用于switch,if等的解析,endNode 指定结束节点
|
||||
*/
|
||||
private getBranchEnd(node: ELNode, endNode?: ELNode): ELNode | Record<string, number[]> {
|
||||
const endNodes = {}
|
||||
//分支起点的出度,用于判断何时结束
|
||||
let outNum = this.context.getEndNum(node);
|
||||
// 获取后继节点id列表
|
||||
const nodes_id = this.context.getEndList(node).map(n => n.id)
|
||||
// 节点的访问次数
|
||||
const visited_count: Record<string, number> = {}
|
||||
while (true) {
|
||||
for (let i = 0; i < nodes_id.length; i++) {
|
||||
const id = nodes_id[i]
|
||||
if (!id) continue
|
||||
const n = this.context.getNodeById(id)
|
||||
//更新访问次数
|
||||
if (!visited_count[n.id]) visited_count[n.id] = 0
|
||||
visited_count[n.id]++
|
||||
|
||||
//记录每个节点对应的开始节点,用于WHEN解析
|
||||
if (!endNodes[n.id]) endNodes[n.id] = []
|
||||
endNodes[n.id].push(i)
|
||||
|
||||
//访问次数达标,返回该节点
|
||||
if (visited_count[n.id] === outNum) {
|
||||
// 如果是选择节点并且有空链,就将next推后
|
||||
if (node.type === ELType.SWITCH && this.context.getEndList(node).some(x => x.id === n.id)) {
|
||||
return this.getBranchEnd(n)
|
||||
}
|
||||
if (endNode) return endNodes
|
||||
return n
|
||||
}
|
||||
|
||||
|
||||
if (this.context.isEnd(n.id) || n.id === endNode?.id) {//结束节点不再向后或者到达指定的结束节点
|
||||
nodes_id[i] = ""
|
||||
} else if (this.context.getEndNum(n) > 1) { //递归解析
|
||||
nodes_id[i] = (this.getBranchEnd(n) as ELNode).id
|
||||
} else { // 修改为下一个节点
|
||||
nodes_id[i] = this.context.getEndList(n)[0].id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type {GraphConfigData, AnchorNextIdsMap} from "./type";
|
||||
import {cloneDeep} from 'lodash-es';
|
||||
import MyParse from "./MyParse";
|
||||
|
||||
export function parseToLiteFlow(data: GraphConfigData, anchorNextIdsMap: AnchorNextIdsMap): string {
|
||||
data = cloneDeep(data);
|
||||
return new MyParse(data, anchorNextIdsMap).parse().getElString();
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import {ELType, GroupType} from "./ELType";
|
||||
import {NodeTypes} from "../../../types";
|
||||
|
||||
export default class ELNode {
|
||||
// @ts-ignore
|
||||
id: string;
|
||||
// @ts-ignore
|
||||
type: string;
|
||||
// 原始类型
|
||||
originType: string = '';
|
||||
// @ts-ignore
|
||||
groupType: GroupType;
|
||||
// @ts-ignore
|
||||
aliasId: string;
|
||||
// @ts-ignore
|
||||
name: string;
|
||||
// @ts-ignore
|
||||
data: string;
|
||||
// @ts-ignore
|
||||
tag: string;
|
||||
// @ts-ignore
|
||||
nodeId: string;
|
||||
child: ELNode[];
|
||||
// @ts-ignore
|
||||
comingEdgeText: string; //指向该节点边的标签
|
||||
elString: string | undefined;
|
||||
|
||||
properties: Record<string, unknown> | undefined
|
||||
|
||||
//group专用
|
||||
// @ts-ignore
|
||||
startNode: ELNode;
|
||||
// @ts-ignore
|
||||
breakNode: ELNode;
|
||||
// @ts-ignore
|
||||
exceptionNode: ELNode;
|
||||
// @ts-ignore
|
||||
startNum: number;
|
||||
|
||||
//固定值
|
||||
maxLineNum: number
|
||||
|
||||
// 锚点连接的下一个节点的ID
|
||||
anchorsNextIds: {
|
||||
anchorId: string;
|
||||
nextIds: string[];
|
||||
}[] = [];
|
||||
|
||||
constructor(id: string = "") {
|
||||
if (id) this.id = id
|
||||
this.child = []
|
||||
this.maxLineNum = 25
|
||||
}
|
||||
|
||||
public addChild(elNode: ELNode) {
|
||||
if (!this.child) this.child = []
|
||||
this.child.push(elNode);
|
||||
}
|
||||
|
||||
// private assert(condition: boolean, msg: string = "") {
|
||||
// if (!condition) throw new Error(msg);
|
||||
// }
|
||||
|
||||
get childFirstId() {
|
||||
if (!Array.isArray(this.child)) {
|
||||
return '';
|
||||
}
|
||||
if (this.child.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const child = this.child[0];
|
||||
switch (child.type) {
|
||||
case ELType.WHEN:
|
||||
case ELType.THEN:
|
||||
return child.childFirstId;
|
||||
}
|
||||
return child.id;
|
||||
}
|
||||
|
||||
//获取节点对应的EL表达式
|
||||
//在raw的基础上添加tag和data数据
|
||||
public getElString() {
|
||||
const tagStr = this.properties?.tag ? `.tag('${this.properties?.tag}')` : ''
|
||||
const dataStr = this.properties?.data ? `.data('${this.properties?.data}')` : ''
|
||||
|
||||
return `${this.getElStringRaw()}${tagStr}${dataStr}`
|
||||
}
|
||||
|
||||
//获取节点对应的EL表达式
|
||||
public getElStringRaw() {
|
||||
if (this.type === ELType.ID) return this.nodeId;
|
||||
if (this.elString) return this.elString;
|
||||
|
||||
const type = this.type;
|
||||
|
||||
switch (type) {
|
||||
case ELType.IF:
|
||||
return this.getELString_IF()
|
||||
case ELType.GROUP:
|
||||
return this.getELString_Group()
|
||||
case ELType.SWITCH:
|
||||
return this.getELString_SWITCH()
|
||||
case ELType.WHEN:
|
||||
case ELType.THEN:
|
||||
return this.getElString_WHEN_THEN()
|
||||
// 脚本组件需要返回id
|
||||
case NodeTypes.CODE:
|
||||
return this.id;
|
||||
// 默认返回type
|
||||
default:
|
||||
return this.type;
|
||||
}
|
||||
}
|
||||
|
||||
// 给每一行插入空格,用于数据的格式化
|
||||
private insertSpace(s: string): string {
|
||||
const res = s.split("\n").join("\n ")
|
||||
return ` ${res}`
|
||||
}
|
||||
|
||||
|
||||
//各种节点获取ELstring方法
|
||||
//THEN和WHEN放到一起
|
||||
private getElString_WHEN_THEN() {
|
||||
const children = this.child || [];
|
||||
// 这里的id只能是switch链中的
|
||||
let idStr = ''
|
||||
let tagStr = '';
|
||||
if (this.aliasId) {
|
||||
idStr = this.aliasId.startsWith('tag:')
|
||||
? `.tag("${this.aliasId.substring(4)}")`
|
||||
: `.id("${this.aliasId}")`
|
||||
}
|
||||
if (this.childFirstId) {
|
||||
tagStr = `.tag("${this.childFirstId}")`;
|
||||
}
|
||||
//加上空格回车进行格式化
|
||||
const childrenELStrings = children.map(item => item.getElString())
|
||||
const joins = childrenELStrings.join(",");
|
||||
|
||||
//只有一个节点,不需要THEN或者WHEN标识,直接返回
|
||||
if (children.length === 1) {
|
||||
if (!this.aliasId || children[0].type === ELType.THEN || children[0].type === ELType.WHEN) {
|
||||
//这些情况可以直接解包,去掉多余的THEN和WHEN
|
||||
return `${joins}${idStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
//长度比较小,不需要进行格式化,直接单行输出
|
||||
if (joins.length < this.maxLineNum - 6) return `${this.type}(${joins})${tagStr || idStr}`
|
||||
|
||||
const fomatterJoins = this.insertSpace(childrenELStrings.join(",\n"))
|
||||
return `${this.type}(\n${fomatterJoins}\n)${tagStr || idStr}`
|
||||
}
|
||||
|
||||
//对分组节点进行解析
|
||||
private getELString_Group() {
|
||||
const children = this.child || [];
|
||||
//加上空格回车进行格式化
|
||||
const childrenELStrings = children.map(item => item.getElString())
|
||||
const joins = childrenELStrings.join(",");
|
||||
|
||||
// 配置节点解析
|
||||
if (this.groupType === GroupType.CONFIG) {
|
||||
const ignoreStr = this.properties?.ignoreError ? `.ignoreError(true)` : ``;
|
||||
const anyStr = this.properties?.any ? `any(true)` : ``;
|
||||
const mustStr = this.properties?.must ? `must(${this.properties?.must})` : ``;
|
||||
|
||||
return `${joins}${ignoreStr}${anyStr}${mustStr}`
|
||||
}
|
||||
|
||||
//与或非表达式解析
|
||||
if (this.groupType === GroupType.LOGIC) {
|
||||
return this.elString;
|
||||
}
|
||||
const name = this.groupType;
|
||||
// 捕获异常表达式解析
|
||||
if (this.groupType === GroupType.CATCH) {
|
||||
const exceptionStr = this.exceptionNode.getElString();
|
||||
return `${name}(${joins}).DO(${exceptionStr})`
|
||||
}
|
||||
// 循环节点解析
|
||||
const breakStr = !!this.breakNode ? `.BREAK(${this.breakNode.getElString()})` : ""
|
||||
const startStr = (this.startNode?.nodeId) ? this.startNode.getElString() : this.startNum;
|
||||
if (children.length === 1) return `${name}(${startStr}).DO(${joins})${breakStr}`;
|
||||
else return `${name}(${startStr}).DO(WHEN(${joins}))${breakStr}`;
|
||||
}
|
||||
|
||||
private getELString_IF() {
|
||||
const children = this.child || [];
|
||||
//加上空格回车进行格式化
|
||||
const childrenELStrings = children.map(item => item.getElString())
|
||||
const joins = childrenELStrings.join(",");
|
||||
|
||||
//长度比较小,不需要进行格式化,直接单行输出
|
||||
if (joins.length < this.maxLineNum - 4) return `IF(${joins})`
|
||||
|
||||
const fomatterJoins = this.insertSpace(childrenELStrings.join(",\n"))
|
||||
return `IF(\n${fomatterJoins}\n)`;
|
||||
}
|
||||
|
||||
private getELString_SWITCH() {
|
||||
// 优化switch的child
|
||||
this.parseSwitchChildren();
|
||||
|
||||
const children = this.child || [];
|
||||
//加上空格回车进行格式化
|
||||
const childrenELStrings = children.map(item => item.getElString())
|
||||
const joins = childrenELStrings.join(",");
|
||||
|
||||
// 原始类型
|
||||
const ot = this.originType;
|
||||
// ID Tag
|
||||
const idStr = `${ot ? ot : NodeTypes.SWITCH}.tag('${this.nodeId}')`;
|
||||
|
||||
const switchTag = `.tag('${this.id}')`;
|
||||
|
||||
if (joins.length < this.maxLineNum - 13 - this.nodeId.length) {
|
||||
return `SWITCH(${idStr}).to(${joins})${switchTag}`;
|
||||
}
|
||||
|
||||
const fomatterJoins = this.insertSpace(childrenELStrings.join(",\n"))
|
||||
return `SWITCH(${idStr}).to(\n${fomatterJoins}\n)${switchTag}`;
|
||||
}
|
||||
|
||||
private parseSwitchChildren() {
|
||||
if (this.anchorsNextIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const children = this.child || [];
|
||||
if (children.length <= this.anchorsNextIds.length) {
|
||||
return;
|
||||
}
|
||||
for (const {nextIds} of this.anchorsNextIds) {
|
||||
if (nextIds.length <= 1) {
|
||||
continue;
|
||||
}
|
||||
const whenNode = new ELNode();
|
||||
whenNode.type = ELType.WHEN;
|
||||
|
||||
for (let nextIdIdx = 0; nextIdIdx < nextIds.length; nextIdIdx++) {
|
||||
const nextId = nextIds[nextIdIdx];
|
||||
const idx = children.findIndex(childNode => {
|
||||
let childId = childNode.id;
|
||||
if (!childId && childNode.child.length > 0) {
|
||||
childId = childNode.child[0].id;
|
||||
}
|
||||
return childId === nextId || childNode.childFirstId === nextId;
|
||||
});
|
||||
if (idx !== -1) {
|
||||
if (nextIdIdx === 0) {
|
||||
whenNode.addChild(children.splice(idx, 1, whenNode)[0]);
|
||||
} else {
|
||||
whenNode.addChild(children.splice(idx, 1)[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.child = children;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export enum ELType {
|
||||
ID = "ID",
|
||||
WHEN = "WHEN",
|
||||
THEN = "THEN",
|
||||
SWITCH = "SWITCH",
|
||||
IF = "IF",
|
||||
GROUP = "GROUP",
|
||||
BREAK = "BREAK",
|
||||
OR = "OR",
|
||||
AND = "AND",
|
||||
NOT = "NOT"
|
||||
}
|
||||
|
||||
export enum GroupType {
|
||||
CATCH = "CATCH",
|
||||
LOGIC = "LOGIC",
|
||||
CONFIG = "CONFIG",
|
||||
FOR = "FOR",
|
||||
WHILE = "WHILE",
|
||||
ITERATOR = "ITERATOR",
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {LogicFlow} from "@logicflow/core";
|
||||
|
||||
export type NodeConfig = LogicFlow.NodeConfig
|
||||
export type GraphConfigData = Required<LogicFlow.GraphConfigData>;
|
||||
|
||||
export type AnchorNextIdsMap = Map<string, string[]>;
|
||||
@@ -0,0 +1,161 @@
|
||||
<!-- 添加节点对话框 -->
|
||||
<template>
|
||||
<div :class="['add-node-dialog', {'show': visible}]" @click.stop>
|
||||
<template v-for="(node, idx) of options" :key="idx">
|
||||
<a-divider v-if="node.divider" style="margin: 4px 0;"/>
|
||||
<a-space v-else class="add-node-item" @click.stop="onAdd(node.type)">
|
||||
<NodeIcon :type="node.type"/>
|
||||
<span class="airag-node-label">{{ node.label }}</span>
|
||||
<a-tooltip v-if="node.docs" title="查看文档">
|
||||
<a class="airag-node-docs-btn" :href="node.docs" target="_blank" @click.stop>
|
||||
<Icon icon="material-symbols:menu-book-outline-rounded" color="#666"/>
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {NodeConfigMap, NodeTypeOrder} from "../../const";
|
||||
import NodeIcon from "../../components/NodeIcon.vue";
|
||||
|
||||
defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['add'])
|
||||
|
||||
const options: Recordable[] = [];
|
||||
|
||||
let nodeCount = 0;
|
||||
let dividersCount = 0;
|
||||
// 初始化节点选项
|
||||
for (const type of NodeTypeOrder) {
|
||||
if (NodeTypeOrder.isDivider(type)) {
|
||||
dividersCount++;
|
||||
options.push({divider: true})
|
||||
} else {
|
||||
nodeCount++;
|
||||
const nodeCfg = NodeConfigMap.get(type);
|
||||
const label = !nodeCfg ? '未知' : nodeCfg.label;
|
||||
options.push({
|
||||
type: type,
|
||||
label,
|
||||
docs: nodeCfg?.docs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// dialog宽度
|
||||
const dialogWidth = 180;
|
||||
// dialog边框
|
||||
const dialogBorder = 2;
|
||||
|
||||
// 具有margin的节点数量
|
||||
// 分割线的下一个节点和dialog最顶部的节点没有margin,所以减去分割线的数量再减去1即可得到具有margin的节点数量
|
||||
const marginCount = nodeCount - dividersCount - 1;
|
||||
const marginTopSize = 6;
|
||||
|
||||
// 每项节点的高度
|
||||
const nodeItemHeight = 42;
|
||||
// 分割线的高度(margin + height)
|
||||
const dividerHeight = 8 + 1;
|
||||
|
||||
// 计算 dialog 高度
|
||||
const dialogHeight = dialogBorder +
|
||||
// node的高度
|
||||
nodeCount * nodeItemHeight
|
||||
// 分割线的高度
|
||||
+ dividersCount * dividerHeight
|
||||
// node之间的间距
|
||||
+ marginCount * marginTopSize;
|
||||
|
||||
// 样式变量
|
||||
const __dialogWidth = dialogWidth + 'px';
|
||||
const __dialogHeight = dialogHeight + 'px';
|
||||
const __nodeHeight = nodeItemHeight + 'px';
|
||||
const __nodeMarginTop = marginTopSize + 'px';
|
||||
const __topNormal = (-dialogHeight / 2 + 30) + 'px';
|
||||
const __rightNormal = (-dialogWidth / 2 - 20) + 'px';
|
||||
const __topShow = (-dialogHeight / 2 + 20) + 'px';
|
||||
const __rightShow = (-dialogWidth - 24) + 'px';
|
||||
|
||||
function onAdd(type: string) {
|
||||
emit('add', type)
|
||||
}
|
||||
|
||||
</script>
|
||||
<style lang="less">
|
||||
//noinspection LessUnresolvedVariable
|
||||
@prefix-cls: ~'@{namespace}-airag-base-node-container';
|
||||
|
||||
.@{prefix-cls} {
|
||||
.add-node-dialog {
|
||||
width: v-bind(__dialogWidth);
|
||||
height: v-bind(__dialogHeight);
|
||||
background-color: #fff;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px 0 #ced0d5;
|
||||
opacity: 0;
|
||||
transform: scale(0.05);
|
||||
pointer-events: none;
|
||||
transition: box-shadow 0.15s, opacity 0.3s, right 0.3s, transform 0.3s;
|
||||
|
||||
position: absolute;
|
||||
top: v-bind(__topNormal);
|
||||
right: v-bind(__rightNormal);
|
||||
|
||||
&.show {
|
||||
top: v-bind(__topShow);
|
||||
right: v-bind(__rightShow);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.add-node-item {
|
||||
width: 100%;
|
||||
height: v-bind(__nodeHeight);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
transition: background-color 0.3s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
|
||||
.airag-node-docs-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
& + .add-node-item {
|
||||
margin-top: v-bind(__nodeMarginTop);
|
||||
}
|
||||
|
||||
.airag-node-docs-btn {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 6px;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
svg {
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.add-node-dialog {
|
||||
box-shadow: 0 4px 8px 0 #c2c2c2;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
import type {NodeStepStatus} from "../../store/runStore";
|
||||
import {BezierEdge, BezierEdgeModel, h as lfh} from '@logicflow/core'
|
||||
import {AddIconSvg} from "../../common/svg";
|
||||
import {prefixCls, edgeActionSize} from "./const";
|
||||
import {genSnowflakeId} from "../../utils/snowflake-id";
|
||||
|
||||
class BaseBezierEdge extends BezierEdge {
|
||||
|
||||
getEdge() {
|
||||
const edge = super.getEdge()
|
||||
const isHover = this.props.model.isHovered
|
||||
|
||||
const {isSilentMode} = this.props.graphModel.editConfigModel
|
||||
if (!isSilentMode && isHover) {
|
||||
edge.props.stroke = '#1890ff'
|
||||
}
|
||||
|
||||
return edge;
|
||||
}
|
||||
|
||||
getAppendWidth() {
|
||||
return lfh("g", {}, super.getAppendWidth(), this.getActionShape());
|
||||
}
|
||||
|
||||
getActionShape() {
|
||||
const {startPoint, endPoint} = this.props.model
|
||||
return lfh("foreignObject", {
|
||||
style: {},
|
||||
x: ((startPoint.x + endPoint.x - edgeActionSize) / 2) + 5,
|
||||
y: ((startPoint.y + endPoint.y - edgeActionSize) / 2) + 5,
|
||||
width: edgeActionSize,
|
||||
height: edgeActionSize,
|
||||
}, this.getActionRender())
|
||||
}
|
||||
|
||||
// 自定义 action 组件
|
||||
getActionRender() {
|
||||
const {isSilentMode} = this.props.graphModel.editConfigModel
|
||||
const isHover = isSilentMode ? false : this.props.model.isHovered;
|
||||
return lfh('div', {
|
||||
className: `${prefixCls}-edge-action ${isHover ? 'hover' : ''}`,
|
||||
onClick: (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// this.props.graphModel.openEdgeAnimation(this.props.model.id);
|
||||
// 删除连线
|
||||
this.props.graphModel.deleteEdgeById(this.props.model.id)
|
||||
this.props.graphModel.$J.repaintGraph();
|
||||
},
|
||||
dangerouslySetInnerHTML: {__html: AddIconSvg},
|
||||
})
|
||||
}
|
||||
|
||||
// 不渲染箭头
|
||||
getStartArrow() {
|
||||
return lfh("g", {});
|
||||
}
|
||||
|
||||
getEndArrow() {
|
||||
return lfh("g", {});
|
||||
}
|
||||
|
||||
// 点击连线时不置顶
|
||||
toFront() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 自定义边
|
||||
class BaseBezierEdgeModel extends BezierEdgeModel {
|
||||
|
||||
initEdgeData(data: any): void {
|
||||
if (!data.id) {
|
||||
data.id = genSnowflakeId();
|
||||
}
|
||||
super.initEdgeData(data)
|
||||
}
|
||||
|
||||
getData() {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
sourceNodeId: this.sourceNodeId,
|
||||
targetNodeId: this.targetNodeId,
|
||||
sourceAnchorId: this.sourceAnchorId,
|
||||
targetAnchorId: this.targetAnchorId,
|
||||
pointsList: this.pointsList,
|
||||
} as any
|
||||
}
|
||||
|
||||
getEdgeStyle() {
|
||||
const style = super.getEdgeStyle();
|
||||
style.stroke = '#afafaf';
|
||||
style.strokeWidth = 2;
|
||||
return style;
|
||||
}
|
||||
|
||||
getOutlineStyle() {
|
||||
const style = super.getOutlineStyle()
|
||||
// 去掉节点外框虚线
|
||||
style.stroke = 'none'
|
||||
if (style.hover) {
|
||||
style.hover.stroke = 'none'
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
getEdgeAnimationStyle() {
|
||||
const style = super.getEdgeAnimationStyle()
|
||||
const runStatus = this.properties?.runStatus as NodeStepStatus
|
||||
if (runStatus === 'running') {
|
||||
style.stroke = '#67b7ff'
|
||||
} else if (runStatus === 'success') {
|
||||
style.stroke = '#52c41a'
|
||||
} else if (runStatus === 'fail') {
|
||||
style.stroke = '#f5222d'
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const BaseEdge = {
|
||||
type: 'base-edge',
|
||||
view: BaseBezierEdge,
|
||||
model: BaseBezierEdgeModel
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
<!-- 节点容器父组件 -->
|
||||
<template>
|
||||
<div ref="containerRef" :class="boxClass" :style="boxStyle">
|
||||
<div class="header">
|
||||
<div class="icon" :title="$node.id">
|
||||
<slot name="icon">
|
||||
<NodeIcon :type="$node.type"/>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="text airag-node-label">
|
||||
<span>{{ $node.properties.text }}</span>
|
||||
</div>
|
||||
<div class="extra">
|
||||
<a-dropdown
|
||||
v-if="!isSilentMode && !hideAction"
|
||||
:trigger="['click']"
|
||||
placement="bottomRight"
|
||||
overlayClassName="airag-node-action-dropdown"
|
||||
:getPopupContainer="() => containerRef"
|
||||
>
|
||||
<!-- :align="{'offset': [0, -24]}" -->
|
||||
<template #overlay>
|
||||
<a-menu style="width: 240px;" @click="onActionClick">
|
||||
<a-menu-item key="copy">
|
||||
<a-space>
|
||||
<Icon icon="ant-design:copy" :size="16"/>
|
||||
<span>复制</span>
|
||||
</a-space>
|
||||
</a-menu-item>
|
||||
<a-menu-divider/>
|
||||
<a-menu-item key="delete" class="hover-red">
|
||||
<a-space>
|
||||
<Icon icon="ant-design:delete" :size="16"/>
|
||||
<span>删除</span>
|
||||
</a-space>
|
||||
<div class="shortcut-keys-tip">
|
||||
<a-tooltip title="选中节点时按下 Delete 键即可删除">Del</a-tooltip>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<div class="action-item dropdown" data-is-node-action @click.stop>
|
||||
<Icon icon="ant-design:ellipsis" :size="24"/>
|
||||
</div>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="runStatus[0]" :class="['run-status', runStatus[0]]">
|
||||
<a-space v-if="runStatus[0] === 'waiting'">
|
||||
<Icon icon="tabler:clock" :size="12"/>
|
||||
<span>等待中</span>
|
||||
</a-space>
|
||||
<a-space v-else-if="runStatus[0] === 'running'">
|
||||
<Icon icon="eos-icons:bubble-loading" :size="12"/>
|
||||
<span>运行中</span>
|
||||
</a-space>
|
||||
<a-space v-else-if="runStatus[0] === 'success'">
|
||||
<Icon icon="ix:success" :size="12"/>
|
||||
<span>运行成功</span>
|
||||
<span>耗时:{{ runStatus[1] }}</span>
|
||||
</a-space>
|
||||
<a-space v-else-if="runStatus[0] === 'fail'">
|
||||
<Icon icon="ix:namur-failure-filled" :size="12"/>
|
||||
<span>运行失败</span>
|
||||
<span>耗时:{{ runStatus[1] }}</span>
|
||||
</a-space>
|
||||
</div>
|
||||
<div v-else-if="$node.properties.remarks" class="remarks" :title="$node.properties.remarks">
|
||||
<span>{{ $node.properties.remarks }}</span>
|
||||
</div>
|
||||
<AddNodeDialog :visible="addNodeDialog.visible" @add="onNodeAdd"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {NodeStepStatus} from "../../store/runStore";
|
||||
import {useRunStore} from "../../store/runStore";
|
||||
import {ref, reactive, watch, watchEffect, computed, nextTick, onMounted, onUnmounted, inject} from 'vue'
|
||||
import {addResizeListener, removeResizeListener} from "@/utils/event";
|
||||
import {useDesign} from "@/hooks/web/useDesign";
|
||||
import Icon from "@/components/Icon";
|
||||
import AddNodeDialog from "./AddNodeDialog.vue";
|
||||
import NodeIcon from "../../components/NodeIcon.vue";
|
||||
|
||||
const {prefixCls} = useDesign('airag-base-node-container');
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object as PropType<Recordable>,
|
||||
// required: true,
|
||||
},
|
||||
graph: {
|
||||
type: Object as PropType<Recordable>,
|
||||
// required: true,
|
||||
},
|
||||
hideAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onUpdateNode: Function,
|
||||
})
|
||||
const getProps = () => ({node: props.node!, graph: props.graph!})
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
|
||||
const $node = reactive({
|
||||
id: '',
|
||||
type: '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
// 是否选中
|
||||
isSelected: false,
|
||||
properties: {} as Recordable,
|
||||
})
|
||||
|
||||
const editConfigModel = inject<Recordable>('editConfigModel', {});
|
||||
|
||||
const isSilentMode = computed(() => editConfigModel?.isSilentMode);
|
||||
|
||||
const addNodeDialog = reactive({
|
||||
visible: false,
|
||||
payload: {} as Recordable,
|
||||
});
|
||||
|
||||
const runStore = useRunStore();
|
||||
|
||||
// 节点运行状态
|
||||
const runStatus = computed<[NodeStepStatus, string]>(() => {
|
||||
// 非运行和完成状态,不显示节点运行状态
|
||||
if (!(runStore.isRunning || runStore.isFinished)) {
|
||||
return ['', ''];
|
||||
}
|
||||
const step = runStore.nodeSteps.find(step => step.node.id === $node.id)
|
||||
return step ? [step.status, step.timeText] : ['waiting', ''];
|
||||
})
|
||||
|
||||
const boxClass = computed(() => {
|
||||
const statusClass: string[] = [];
|
||||
if (runStatus.value[0]) {
|
||||
const runClass = `run-status-${runStatus.value[0]}`;
|
||||
statusClass.push(runClass);
|
||||
}
|
||||
return [
|
||||
prefixCls,
|
||||
{
|
||||
selected: $node.isSelected,
|
||||
},
|
||||
statusClass,
|
||||
];
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const {graph, node} = getProps()
|
||||
graph.$J.setEdgeRunStatus(node, runStatus.value[0]);
|
||||
})
|
||||
|
||||
const boxStyle = computed(() => {
|
||||
return {
|
||||
// -20px 是为了让锚点显示在外部
|
||||
width: `${$node.width - 20}px`,
|
||||
// 'min-height': `${$node.height}px`,
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => $node.isSelected, (val, oldVal) => {
|
||||
if (oldVal && !val && addNodeDialog.visible) {
|
||||
setAddNodeDialogVisible(false)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => $node.properties.remarks, () => nextTick(() => updateHeight()));
|
||||
|
||||
onMounted(() => {
|
||||
const {graph, node} = getProps()
|
||||
|
||||
// graph.$J.on('hello', {node})
|
||||
graph.$J.register(node, {
|
||||
onNodeUpdated() {
|
||||
updateNode()
|
||||
},
|
||||
|
||||
onNodeClick(_eventData) {
|
||||
},
|
||||
onGraphUpdated(_eventData) {
|
||||
},
|
||||
onPropertiesChange(eventData) {
|
||||
$node.properties = eventData.properties
|
||||
},
|
||||
|
||||
toggleAddNodeDialog(payload: Recordable) {
|
||||
setAddNodeDialogVisible(!addNodeDialog.visible)
|
||||
addNodeDialog.payload = payload;
|
||||
}
|
||||
});
|
||||
|
||||
addResizeListener(containerRef.value, onContainerResize)
|
||||
|
||||
// 更新高度
|
||||
updateHeight()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
removeResizeListener(containerRef.value, onContainerResize)
|
||||
})
|
||||
|
||||
// 监听容器大小变化
|
||||
function onContainerResize() {
|
||||
updateHeight()
|
||||
}
|
||||
|
||||
function setAddNodeDialogVisible(visible: boolean) {
|
||||
addNodeDialog.visible = visible
|
||||
const {node} = getProps()
|
||||
node.draggable = !visible;
|
||||
}
|
||||
|
||||
function updateHeight(height?: number) {
|
||||
if (!containerRef.value) {
|
||||
return
|
||||
}
|
||||
const {node, graph} = getProps()
|
||||
if (height == null) {
|
||||
height = containerRef.value.offsetHeight;
|
||||
}
|
||||
const oldHeight = node.height;
|
||||
let deltaY: number, newY: number;
|
||||
// 由于节点的 y 坐标是中心点,所以需要调整 y 坐标,使节点位置相对不变
|
||||
if (oldHeight > height) {
|
||||
deltaY = (oldHeight - height) / 2;
|
||||
newY = node.y - deltaY;
|
||||
} else {
|
||||
deltaY = (height - oldHeight) / 2;
|
||||
newY = node.y + deltaY;
|
||||
}
|
||||
|
||||
node.setProperties({height});
|
||||
node.moveTo(node.x, newY);
|
||||
|
||||
// 更新锚点位置
|
||||
const anchors = node.getDefaultAnchor();
|
||||
for (const anchor of anchors) {
|
||||
const fnName = anchor.type === 'right' ? [
|
||||
'getAnchorOutgoingEdge', 'updateStartPoint'
|
||||
] : [
|
||||
'getAnchorIncomingEdge', 'updateEndPoint'
|
||||
];
|
||||
const edges = graph[fnName[0]](anchor.id);
|
||||
const edge = edges?.[0];
|
||||
if (edge) {
|
||||
edge[fnName[1]]({x: anchor.x, y: anchor.y});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateNode() {
|
||||
const {node} = getProps();
|
||||
$node.id = node.id
|
||||
$node.type = node.type
|
||||
$node.width = node.width
|
||||
$node.height = node.height
|
||||
$node.isSelected = node.isSelected
|
||||
$node.properties = node.properties
|
||||
|
||||
if (typeof props.onUpdateNode === 'function') {
|
||||
props.onUpdateNode($node)
|
||||
}
|
||||
}
|
||||
|
||||
updateNode()
|
||||
|
||||
function onActionClick({key}) {
|
||||
const {graph, node} = getProps()
|
||||
graph.$J.doAction(key, {node})
|
||||
}
|
||||
|
||||
function onNodeAdd(nodeType: string) {
|
||||
const {graph, node} = getProps();
|
||||
const payload: Recordable = {
|
||||
nodeType,
|
||||
prevData: {node},
|
||||
}
|
||||
if (addNodeDialog.payload?.anchor) {
|
||||
payload.prevData.anchor = addNodeDialog.payload.anchor;
|
||||
}
|
||||
graph.$J.doAction('add-node', payload);
|
||||
setAddNodeDialogVisible(false)
|
||||
addNodeDialog.payload = {};
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
$node,
|
||||
updateHeight,
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
//noinspection LessUnresolvedVariable
|
||||
@prefix-cls: ~'@{namespace}-airag-base-node-container';
|
||||
|
||||
.@{prefix-cls} {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 2px 4px 0 #d6d6d6;
|
||||
transition: box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 8px 0 #c2c2c2;
|
||||
}
|
||||
|
||||
&:where(.selected) {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&.run-status {
|
||||
&-waiting {
|
||||
border-color: #cccccc;
|
||||
}
|
||||
|
||||
&-running {
|
||||
border-color: #67b7ff;
|
||||
}
|
||||
|
||||
&-success {
|
||||
border-color: #52c41a;
|
||||
}
|
||||
|
||||
&-fail {
|
||||
border-color: #f5222d;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
|
||||
.icon {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.extra {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
|
||||
.action-item {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
|
||||
cursor: pointer;
|
||||
background-color: #ffffff;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
&.dropdown {
|
||||
line-height: 38px;
|
||||
|
||||
svg {
|
||||
stroke: #333333;
|
||||
//stroke-width: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.run-status {
|
||||
margin-top: 8px;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.waiting {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
&.running {
|
||||
color: #67b7ff;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.fail {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.remarks {
|
||||
margin-top: 8px;
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.airag-node-action-dropdown {
|
||||
> .ant-dropdown-menu {
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
padding: 4px 6px;
|
||||
|
||||
&:hover {
|
||||
&.hover-red {
|
||||
color: #f5222d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-keys-tip {
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
padding: 2px;
|
||||
background-color: #f5f5f5;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!-- 节点图标父组件 -->
|
||||
<template>
|
||||
<div :class="getClass" :style="getStyle">
|
||||
<slot name="icon">
|
||||
<Icon :icon="icon" :color="iconColor"/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue";
|
||||
import {useDesign} from "@/hooks/web/useDesign";
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#66ccff',
|
||||
},
|
||||
// 旋转角度
|
||||
rotate: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
strokeWidth: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: '#fff',
|
||||
},
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
const {prefixCls} = useDesign('airag-base-node-icon');
|
||||
|
||||
const getClass = computed(() => {
|
||||
const cls = [prefixCls]
|
||||
if (props.className) {
|
||||
cls.push(props.className)
|
||||
}
|
||||
return cls
|
||||
})
|
||||
|
||||
const getStyle = computed(() => {
|
||||
const rotate = props.rotate > 0 ? {
|
||||
transform: `rotate(${props.rotate}deg)`,
|
||||
} : {}
|
||||
return {
|
||||
backgroundColor: props.color,
|
||||
...rotate,
|
||||
}
|
||||
})
|
||||
|
||||
const getStorkWidth = computed(() => {
|
||||
if (props.strokeWidth > 0) {
|
||||
return props.strokeWidth + 'px'
|
||||
}
|
||||
return props.strokeWidth
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
//noinspection LessUnresolvedVariable
|
||||
@prefix-cls: ~'@{namespace}-airag-base-node-icon';
|
||||
|
||||
.@{prefix-cls} {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
|
||||
.app-iconify {
|
||||
svg {
|
||||
stroke: v-bind(iconColor);
|
||||
stroke-width: v-bind(getStorkWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div v-if="value || $slots.default" class="node-key-value">
|
||||
<div class="node-key-value-item">
|
||||
<div class="node-key-value-item-key" :style="labelStyle">
|
||||
<slot name="label">
|
||||
<span>{{ label }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="node-key-value-item-value">
|
||||
<slot>
|
||||
<span>{{ value }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
labelWidth: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const labelStyle = computed(() => {
|
||||
const style: Record<string, string> = {}
|
||||
if (props.labelWidth) {
|
||||
style.width = props.labelWidth + 'px'
|
||||
}
|
||||
// style.backgroundColor = 'red'
|
||||
return style
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.node-key-value {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.node-key-value-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.node-key-value-item-key {
|
||||
color: #999;
|
||||
text-align: right;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.node-key-value-item-value {
|
||||
flex: 1;
|
||||
// 不换行
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<template v-for="item in nodeKVS" :key="item.label">
|
||||
<template v-if="item.emptyAction === 'hidden'">
|
||||
<NodeKV :label="item.label" :value="item.value" :labelWidth="maxLabelWidth"/>
|
||||
</template>
|
||||
<template v-else-if="item.emptyAction === 'show'">
|
||||
<NodeKV :label="item.label" :value="item.value" :labelWidth="maxLabelWidth">
|
||||
<span>{{ item.value }}</span>
|
||||
</NodeKV>
|
||||
</template>
|
||||
<template v-else-if="item.emptyAction === 'tip'">
|
||||
<NodeKV :label="item.label" :labelWidth="maxLabelWidth">
|
||||
<span v-if="item.value">{{ item.value }}</span>
|
||||
<span v-else style="color: #d69696;">{{ item.emptyTip }}</span>
|
||||
</NodeKV>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {KVItemType} from './const'
|
||||
import {watch, ref} from 'vue'
|
||||
import NodeKV from './NodeKV.vue'
|
||||
|
||||
const props = defineProps({
|
||||
kvs: {
|
||||
type: Array as PropType<KVItemType[]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 记录最大的label宽度
|
||||
const maxLabelWidth = ref<number>()
|
||||
const nodeKVS = ref<KVItemType[]>([])
|
||||
|
||||
watch(() => props.kvs, () => {
|
||||
maxLabelWidth.value = void 0
|
||||
nodeKVS.value = props.kvs.map((item) => {
|
||||
const kv = {...item}
|
||||
if (!kv.emptyAction) {
|
||||
kv.emptyAction = 'hidden'
|
||||
}
|
||||
if (!kv.emptyTip) {
|
||||
kv.emptyTip = '尚未选择'
|
||||
}
|
||||
let width = kv.width
|
||||
if (!width) {
|
||||
width = kv.label.length * 14.5
|
||||
}
|
||||
if (!maxLabelWidth.value || width > maxLabelWidth.value) {
|
||||
maxLabelWidth.value = width
|
||||
}
|
||||
return kv;
|
||||
})
|
||||
|
||||
}, {deep: true, immediate: true,})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import LogicFlow from '@logicflow/core'
|
||||
import {VueNodeModel} from '@logicflow/vue-node-registry'
|
||||
import {NodeConfigMap} from "../../const";
|
||||
import {nodeDefWidth, nodeDefHeight} from "./const";
|
||||
import {genSnowflakeId} from "../../utils/snowflake-id";
|
||||
|
||||
import {usePropStoreWithOut} from '../../store/propStore'
|
||||
|
||||
const propStore = usePropStoreWithOut();
|
||||
|
||||
export class BaseNodeModel extends VueNodeModel {
|
||||
|
||||
get nodeConfig() {
|
||||
return NodeConfigMap.get(this.type);
|
||||
}
|
||||
|
||||
initNodeData(data: LogicFlow.NodeConfig) {
|
||||
if (!data.id) {
|
||||
data.id = genSnowflakeId();
|
||||
}
|
||||
delete data.text;
|
||||
super.initNodeData(data);
|
||||
if (!this.nodeConfig) {
|
||||
return
|
||||
}
|
||||
const {params, methods} = this.nodeConfig
|
||||
this.width = params?.width ?? nodeDefWidth;
|
||||
this.height = params?.height ?? nodeDefHeight;
|
||||
|
||||
if (typeof methods?.initNodeData === 'function') {
|
||||
methods.initNodeData.call(this, data)
|
||||
}
|
||||
|
||||
propStore.setProps(this);
|
||||
|
||||
const isSelfNode = (sourceNode: any, targetNode: any) => sourceNode?.id === targetNode?.id;
|
||||
// 限制作为源节点时的规则(只能从右侧连出)
|
||||
this.sourceRules.push({
|
||||
validate: (sourceNode, targetNode, sourceAnchor, targetAnchor) => {
|
||||
if (isSelfNode(sourceNode, targetNode)) {
|
||||
return false;
|
||||
}
|
||||
if (sourceAnchor == null || targetAnchor == null) {
|
||||
return false;
|
||||
}
|
||||
return sourceAnchor.type === 'right' && targetAnchor.type === 'left';
|
||||
},
|
||||
message: "右侧锚点只能连接左侧锚点!"
|
||||
});
|
||||
// 限制作为目标节点时的规则(只能从左侧连入)
|
||||
this.targetRules.push({
|
||||
message: "左侧锚点只能连接右侧锚点!",
|
||||
validate: (sourceNode, targetNode, sourceAnchor, targetAnchor) => {
|
||||
if (isSelfNode(sourceNode, targetNode)) {
|
||||
return false;
|
||||
}
|
||||
if (sourceAnchor == null || targetAnchor == null) {
|
||||
return false;
|
||||
}
|
||||
if (targetAnchor.type === 'left' && sourceAnchor.type === 'right') {
|
||||
// 判断是否已经有连线
|
||||
const edges = this.graphModel.getNodeIncomingEdge(targetNode!.id);
|
||||
if (edges && edges.length > 0) {
|
||||
const hasEdge = edges.some((edge) => edge.sourceAnchorId === sourceAnchor.id);
|
||||
return !hasEdge;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// 限制作为目标节点时的规则(禁止循环连接,不是自己连自己,而是A-B-A或A-B-C-A这种情况)
|
||||
this.targetRules.push({
|
||||
message: "禁止循环连接!",
|
||||
validate: (sourceNode, targetNode) => {
|
||||
// 1. 获取 sourceNode 的所有之前的节点
|
||||
const prevNodes = this.graphModel.$J.getAllPrevNodes(sourceNode);
|
||||
// 2. 判断 targetNode 是否在 prevNodes 中
|
||||
const isInPrevNodes = prevNodes.some((node) => node.id === targetNode!.id);
|
||||
// 3. 如果 isInPrevNodes 为 true,则返回 false,否则返回 true
|
||||
return !isInPrevNodes;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getTextStyle() {
|
||||
const style = super.getTextStyle();
|
||||
style.color = 'transparent';
|
||||
return style;
|
||||
}
|
||||
|
||||
// 重写节点样式
|
||||
getNodeStyle() {
|
||||
const style = super.getNodeStyle();
|
||||
style.overflow = 'visible';
|
||||
return style;
|
||||
}
|
||||
|
||||
// 重写节点外框样式
|
||||
getOutlineStyle() {
|
||||
const style = super.getOutlineStyle()
|
||||
// 去掉节点外框虚线
|
||||
style.stroke = 'none'
|
||||
if (style.hover) {
|
||||
style.hover.stroke = 'none'
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义锚点
|
||||
*/
|
||||
getDefaultAnchor() {
|
||||
if (!this.nodeConfig) {
|
||||
return
|
||||
}
|
||||
const wHalf = this.width / 2;
|
||||
const hHalf = this.height / 2;
|
||||
const constY = (this.y - hHalf) + (nodeDefHeight / 2);
|
||||
const defaultAnchor = [
|
||||
{x: this.x - wHalf, y: constY, id: `${this.id}_input`, type: 'left'},
|
||||
{x: this.x + wHalf, y: constY, id: `${this.id}_output`, type: 'right'},
|
||||
];
|
||||
if (typeof this.nodeConfig.methods?.getDefaultAnchor === 'function') {
|
||||
return this.nodeConfig.methods.getDefaultAnchor.call(this, defaultAnchor);
|
||||
}
|
||||
return defaultAnchor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义节点锚点拖出连接线的样式属性
|
||||
*/
|
||||
getAnchorLineStyle(_anchorInfo) {
|
||||
const style = super.getAnchorLineStyle();
|
||||
style.stroke = '#999999';
|
||||
return style;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// 节点样式
|
||||
.j-airag-vue-node {
|
||||
|
||||
&-content {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
// 锚点样式
|
||||
// svg:first-child 为圆圈图标
|
||||
// svg:last-child 为加号图标
|
||||
&-anchor {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
color: #1890ff;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
svg:last-child {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
// 左侧锚点,只显示圆圈图标
|
||||
&.left {
|
||||
cursor: default;
|
||||
|
||||
&:not(.has-edge) {
|
||||
color: #909090;
|
||||
}
|
||||
|
||||
svg:first-child {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧锚点
|
||||
&.right {
|
||||
// 当没有连线时,显示加号图标
|
||||
svg:last-child {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
// 当有连线时,隐藏加号图标,显示圆圈图标
|
||||
&.has-edge {
|
||||
svg {
|
||||
display: initial;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:first-child {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 鼠标悬浮时,显示加号图标
|
||||
&:hover {
|
||||
svg {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
svg:last-child {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义边的操作按钮
|
||||
&-edge-action {
|
||||
display: none;
|
||||
color: #1890ff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
// 旋转45度,让 + 变成 x
|
||||
transform: rotate(45deg);
|
||||
|
||||
&.hover {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-iconify {
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
stroke-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.airag-node-label {
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
font-family: "思源黑体", serif !important;
|
||||
}
|
||||
|
||||
#rc-tabs-1-panel-1 > div > div > div.jeecg-airag-work-flow-box > div > div > div > svg.lf-canvas-overlay > g > g > g > g > g > foreignObject {
|
||||
background-color: #e8eaf0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {h as lfh} from '@logicflow/core'
|
||||
import {VueNodeView} from '@logicflow/vue-node-registry'
|
||||
import {CircleIconSvg, AddIconSvg} from "../../common/svg";
|
||||
import {prefixCls} from "./const";
|
||||
|
||||
export const AnchorSize = 18
|
||||
export const AnchorRadius = AnchorSize / 2
|
||||
|
||||
export class BaseNodeView extends VueNodeView {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
}
|
||||
|
||||
setHtml(rootEl: SVGForeignObjectElement) {
|
||||
const el = document.createElement('div')
|
||||
el.className = `${prefixCls}-content`
|
||||
el.dataset.nodeId = this.props.model.id
|
||||
this.root = el
|
||||
rootEl.appendChild(el)
|
||||
super.renderVueComponent()
|
||||
}
|
||||
|
||||
confirmUpdate(_rootEl: SVGForeignObjectElement) {
|
||||
}
|
||||
|
||||
/** 重写锚点样式 */
|
||||
getAnchorShape(anchorData: any) {
|
||||
const {x, y, type} = anchorData
|
||||
const isLeft = type === 'left'
|
||||
const isRight = type === 'right'
|
||||
// 判断是否有连线
|
||||
const hasEdge = (() => {
|
||||
const prop = isLeft ? 'targetAnchorId' : 'sourceAnchorId'
|
||||
return this.props.graphModel.edges.some((edge) => edge[prop] === anchorData.id)
|
||||
})();
|
||||
return lfh(
|
||||
'foreignObject',
|
||||
{
|
||||
...anchorData,
|
||||
width: AnchorSize,
|
||||
height: AnchorSize,
|
||||
x: isLeft ? x - 10 : x - 8,
|
||||
y: y - AnchorRadius,
|
||||
style: {
|
||||
'pointer-events': isLeft ? 'none' : void 0,
|
||||
}
|
||||
},
|
||||
[
|
||||
lfh('div', {
|
||||
className: `${prefixCls}-anchor ${isLeft ? 'left' : 'right'} ${hasEdge ? 'has-edge' : ''}`,
|
||||
onClick: (event: MouseEvent) => {
|
||||
// 阻止事件冒泡
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isRight) {
|
||||
this.props.graphModel.$J.doAction('toggle-add-node-dialog', {
|
||||
node: this.props.model,
|
||||
anchor: anchorData,
|
||||
});
|
||||
}
|
||||
},
|
||||
dangerouslySetInnerHTML: {
|
||||
__html: `${CircleIconSvg}${isRight ? AddIconSvg : ''}`,
|
||||
}
|
||||
})
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const prefixCls = 'j-airag-vue-node'
|
||||
|
||||
export const edgeActionSize = 20
|
||||
|
||||
export const nodeDefWidth = 332;
|
||||
export const nodeDefHeight = 62;
|
||||
|
||||
export type KVItemType = {
|
||||
label: string
|
||||
value: string
|
||||
// 空值时的操作,默认为 hidden
|
||||
emptyAction?: 'hidden' | 'show' | 'tip'
|
||||
// 空值时的提示,默认为:尚未选择
|
||||
emptyTip?: string
|
||||
// 标签宽度
|
||||
width?: number
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {BaseNodeModel} from './NodeModel'
|
||||
export {BaseNodeView} from './NodeView'
|
||||
export {BaseEdge} from './BaseBezierEdge'
|
||||
export {default as BaseNodeContainer} from './NodeContainer.vue'
|
||||
export {default as NodeKV} from './NodeKV.vue'
|
||||
export {default as NodeKVS} from './NodeKVS.vue'
|
||||
|
||||
import './NodeStyle.less'
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<NodeIcon icon="mingcute:classify-3-fill" color="#12c499"/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import NodeIcon from "../base-node/NodeIcon.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<BaseNodeContainer ref="containerRef" v-bind="containerProps">
|
||||
<div v-if="$node.caseList?.length" class="classifier-node-content">
|
||||
<div v-for="item of $node.caseList" class="case-item">
|
||||
<div class="case-header">
|
||||
<div>{{ item.label }}</div>
|
||||
</div>
|
||||
<div class="case-category">
|
||||
<div>{{ item.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseNodeContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {watch, nextTick} from 'vue'
|
||||
import {useNode} from "../../hooks/useNode";
|
||||
import {BaseNodeContainer} from "../base-node";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
},
|
||||
graph: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
$node,
|
||||
updateHeight,
|
||||
containerRef, containerProps
|
||||
} = useNode(props, {
|
||||
onUpdateNode($node) {
|
||||
$node.caseList = props.node.$caseList;
|
||||
},
|
||||
});
|
||||
|
||||
// 当 caseList 变化时,更新高度
|
||||
watch(() => $node.value.caseList?.length, () => nextTick(() => updateHeight()));
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
//noinspection LessUnresolvedVariable
|
||||
@prefix-cls: ~'@{namespace}-airag-base-node-container';
|
||||
|
||||
.@{prefix-cls} {
|
||||
.classifier-node-content {
|
||||
.case-item {
|
||||
text-align: right;
|
||||
color: #333333;
|
||||
// margin-bottom: 4px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.case-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.case-category {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
color: #999999;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<!-- AI分类器配置 -->
|
||||
<template>
|
||||
<div class="classifier-setting">
|
||||
<div class="setting-item">
|
||||
<div class="label">输入变量</div>
|
||||
<VarPicker :vars="prevVariables" :item="searchVar" @change="($node) => updateSearchVar($node)"/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">模型</div>
|
||||
<LLMModelSelect v-model:model="modelOpt"/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">分类</div>
|
||||
<template v-for="(cate, cateIdx) of categories">
|
||||
<div class="case-item">
|
||||
<div class="case-header">
|
||||
<div class="case-label">
|
||||
<span>分类 {{ cateIdx + 1 }}</span>
|
||||
</div>
|
||||
<a-space class="case-action">
|
||||
<Icon class="delete" icon="ant-design:delete" @click="onDelCategory(cateIdx)"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-textarea
|
||||
class="case-input"
|
||||
v-model:value="cate.category"
|
||||
placeholder="请输入你的分类主题内容"
|
||||
@blur="() => onUpdateCategory()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<a-button block preIcon="ant-design:plus" @click="onAddCategory">
|
||||
<span>添加分类</span>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label case-label">
|
||||
<div class="c-type">ELSE</div>
|
||||
</div>
|
||||
<div style="color: #aaaaaa">
|
||||
<span>当以上分类都不满足时,执行此分支</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">输出变量</div>
|
||||
<VarListShow :vars="outputParams"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue";
|
||||
import {cloneDeep} from "lodash";
|
||||
import {useMessage} from "@/hooks/web/useMessage";
|
||||
import {useSettings} from "../../hooks/useSettings";
|
||||
import {VarPicker, VarListShow} from "../../components/Vars";
|
||||
import LLMModelSelect from "../llm-node/LLMModelSelect.vue";
|
||||
import {getAnchorId} from "./utils";
|
||||
|
||||
const {createMessage: $message} = useMessage();
|
||||
const props = defineProps({
|
||||
type: {type: String, required: true},
|
||||
node: {type: Object, required: true},
|
||||
properties: {type: Object, required: true},
|
||||
setProperties: {type: Function, required: true},
|
||||
})
|
||||
|
||||
const {lfRef, inputParams, outputParams, prevVariables, createOptionRef} = useSettings(props);
|
||||
|
||||
const modelOpt = createOptionRef<Recordable>('model')
|
||||
|
||||
const categories = createOptionRef<Recordable[]>('categories')
|
||||
|
||||
// 查询变量
|
||||
const searchVar = computed({
|
||||
get(): any {
|
||||
if (!inputParams.value[0]) {
|
||||
return {field: '', nodeId: ''};
|
||||
}
|
||||
return inputParams.value[0];
|
||||
},
|
||||
set(varItem: any) {
|
||||
inputParams.value = [varItem]
|
||||
},
|
||||
})
|
||||
|
||||
// 更新查询变量
|
||||
function updateSearchVar(node: Recordable) {
|
||||
if (!node?.nodeId) {
|
||||
searchVar.value = {field: '', nodeId: ''};
|
||||
} else {
|
||||
searchVar.value = {field: node.field, nodeId: node.nodeId}
|
||||
}
|
||||
}
|
||||
|
||||
function onAddCategory() {
|
||||
categories.value = [
|
||||
...categories.value,
|
||||
{category: '', next: ''},
|
||||
];
|
||||
}
|
||||
|
||||
function onDelCategory(idx: number) {
|
||||
const {node} = props;
|
||||
if (categories.value.length === 1) {
|
||||
$message.warning('请至少保留一个分类');
|
||||
return;
|
||||
}
|
||||
if (!lfRef.value) {
|
||||
return
|
||||
}
|
||||
const graphModel = lfRef.value.graphModel;
|
||||
const {$caseList} = node
|
||||
|
||||
const caseItem = $caseList[idx];
|
||||
const {type} = caseItem;
|
||||
// 删除分支之前,先删除连线
|
||||
const anchorId = getAnchorId(node.id, type, idx + 1);
|
||||
const edges = graphModel.getAnchorOutgoingEdge(anchorId)
|
||||
if (edges?.[0]?.id) {
|
||||
// 删除连线
|
||||
graphModel.deleteEdgeById(edges[0].id)
|
||||
}
|
||||
// 记录需要更改的连线id,由于删除锚点后,该锚点之后的锚点id都会发生变化,所以需要同步修改
|
||||
const needUpdateEdges: Recordable[] = [];
|
||||
for (let i = idx + 1; i < $caseList.length; i++) {
|
||||
const caseItem = $caseList[i];
|
||||
const {type} = caseItem;
|
||||
if (type === 'ELSE') {
|
||||
continue;
|
||||
}
|
||||
const anchorId = getAnchorId(node.id, type, i + 1);
|
||||
const edges = graphModel.getAnchorOutgoingEdge(anchorId)
|
||||
if (edges?.[0]?.id) {
|
||||
needUpdateEdges.push({
|
||||
edge: edges[0],
|
||||
newSourceAnchorId: getAnchorId(node.id, 'CASE', i),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 删除分支
|
||||
categories.value.splice(idx, 1);
|
||||
onUpdateCategory()
|
||||
|
||||
if (needUpdateEdges.length) {
|
||||
setTimeout(() => {
|
||||
for (const up of needUpdateEdges) {
|
||||
const newEdge = {
|
||||
id: up.edge.id,
|
||||
type: up.edge.type,
|
||||
sourceNodeId: up.edge.sourceNodeId,
|
||||
targetNodeId: up.edge.targetNodeId,
|
||||
sourceAnchorId: up.newSourceAnchorId,
|
||||
targetAnchorId: up.edge.targetAnchorId,
|
||||
}
|
||||
graphModel.deleteEdgeById(up.edge.id)
|
||||
graphModel.addEdge(newEdge)
|
||||
}
|
||||
graphModel.$J.repaintGraph();
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateCategory() {
|
||||
categories.value = cloneDeep(categories.value)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.classifier-setting {
|
||||
.case-item {
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
|
||||
.case-header {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
right: 11px;
|
||||
height: 30px;
|
||||
border-radius: 3px 0 0 0;
|
||||
background-color: #ffffff;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
padding: 2px 11px;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.case-label {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.case-action {
|
||||
.app-iconify {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.delete {
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.case-input {
|
||||
padding-top: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {NodeConfig} from "../../types";
|
||||
import {NodeTypes} from "../../types";
|
||||
import LogicFlow from "@logicflow/core";
|
||||
|
||||
import {BaseNodeModel, BaseNodeView} from "../base-node";
|
||||
import {nodeDefWidth, nodeDefHeight} from "../base-node/const";
|
||||
import {useUpdateSettings} from "../../hooks/useSettings";
|
||||
import ClassifierIcon from './ClassifierIcon.vue'
|
||||
import ClassifierSetting from './ClassifierSetting.vue'
|
||||
import ClassifierNodeVue from './ClassifierNode.vue'
|
||||
import {getCaseList, getAnchorId} from "./utils";
|
||||
|
||||
class ClassifierNodeModel extends BaseNodeModel {
|
||||
get $caseList() {
|
||||
return getCaseList(this)
|
||||
}
|
||||
}
|
||||
|
||||
const config: NodeConfig = {
|
||||
type: NodeTypes.CLASSIFIER,
|
||||
label: "分类器",
|
||||
docs: [
|
||||
'https://help.jeecg.com/aigc/flowNodes/nodeClassifier',
|
||||
],
|
||||
components: {
|
||||
icon: ClassifierIcon,
|
||||
setting: ClassifierSetting,
|
||||
},
|
||||
lfNode: {
|
||||
type: NodeTypes.CLASSIFIER,
|
||||
view: BaseNodeView,
|
||||
model: ClassifierNodeModel,
|
||||
component: ClassifierNodeVue,
|
||||
},
|
||||
params: {
|
||||
width: nodeDefWidth,
|
||||
height: nodeDefHeight,
|
||||
},
|
||||
methods: {
|
||||
createNode,
|
||||
checkNode,
|
||||
updateNodeSetting,
|
||||
|
||||
getAnchorId,
|
||||
getDefaultAnchor(this: ClassifierNodeModel, defaultAnchor) {
|
||||
const [leftAnchor, rightAnchor] = defaultAnchor;
|
||||
// 保留左侧锚点
|
||||
const anchorList = [leftAnchor]
|
||||
if (!this.$caseList.length) {
|
||||
return anchorList;
|
||||
}
|
||||
// 根据条件分支数量动态生成锚点
|
||||
for (let i = 0; i < this.$caseList.length; i++) {
|
||||
const caseItem = this.$caseList[i];
|
||||
anchorList.push({
|
||||
id: getAnchorId(this.id, caseItem.type, i + 1),
|
||||
x: rightAnchor.x,
|
||||
y: (rightAnchor.y + 34) + (22 * i) + (22 * i),
|
||||
type: 'right',
|
||||
});
|
||||
}
|
||||
return anchorList;
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const getDefProp = () => {
|
||||
return {
|
||||
outputParams: [
|
||||
{field: 'index', name: '分类索引', type: 'number'},
|
||||
{field: 'content', name: '分类描述', type: 'string'},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createNode(data: Recordable) {
|
||||
const defProp = getDefProp();
|
||||
return {
|
||||
id: data.id,
|
||||
remarks: '',
|
||||
type: config.type,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
properties: {
|
||||
text: data.text ?? config.label,
|
||||
options: {
|
||||
model: {
|
||||
modeId: '',
|
||||
params: {model: '', temperature: 0.7},
|
||||
},
|
||||
categories: [
|
||||
{category: '', next: ''},
|
||||
{category: '', next: ''},
|
||||
],
|
||||
else: {
|
||||
next: '',
|
||||
},
|
||||
},
|
||||
inputParams: [],
|
||||
outputParams: defProp.outputParams,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkNode(lf: LogicFlow, node: any, {
|
||||
findNextIdsByAnchorId,
|
||||
}) {
|
||||
const {edges} = lf.graphModel;
|
||||
const {properties} = node;
|
||||
const {options} = properties;
|
||||
const problems: string[] = [];
|
||||
|
||||
if (!Array.isArray(options?.categories) || options.categories.length === 0) {
|
||||
problems.push('“分类”不能为空');
|
||||
}
|
||||
const caseList = getCaseList(node)
|
||||
for (let i = 0; i < caseList.length; i++) {
|
||||
const caseItem = caseList[i];
|
||||
const anchorId = getAnchorId(node.id, caseItem.type, i + 1);
|
||||
let nextNodeIds = findNextIdsByAnchorId(anchorId, edges)
|
||||
if (nextNodeIds.length === 0) {
|
||||
problems.push(`“${caseItem.label}”未连接下一个节点`);
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
function updateNodeSetting(node: Recordable) {
|
||||
const {mergeIOParams} = useUpdateSettings(node, getDefProp);
|
||||
|
||||
mergeIOParams();
|
||||
|
||||
// 更新老的数据
|
||||
const outputContent = node.properties.outputParams.find((i: Recordable) => i.field === 'content');
|
||||
if (outputContent) {
|
||||
outputContent.name = '分类描述';
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 获取case列表
|
||||
*/
|
||||
export function getCaseList($node: any) {
|
||||
const list: Recordable[] = []
|
||||
const options = $node.properties?.options ?? {}
|
||||
if (Array.isArray(options.categories)) {
|
||||
list.push(
|
||||
...options.categories.map((item: Recordable, index: number) => {
|
||||
return {
|
||||
type: 'CASE',
|
||||
label: `分类 ${index + 1}`,
|
||||
category: item.category || '-',
|
||||
value: item,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
if (options['else']) {
|
||||
list.push({
|
||||
type: 'ELSE',
|
||||
label: 'ELSE',
|
||||
value: options['else'],
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取锚点ID
|
||||
* @param nodeId 节点ID
|
||||
* @param caseType case类型
|
||||
* @param caseIdx case索引
|
||||
*/
|
||||
export function getAnchorId(nodeId: string, caseType: string, caseIdx: number) {
|
||||
const caseId = `case_${caseType === 'ELSE' ? 'else' : caseIdx}`;
|
||||
return `${nodeId}_${caseId}`
|
||||
}
|
||||
|
||||
export function getAnchorIdByChooseIndex(nodeId: string, chooseIndex: number) {
|
||||
const caseType = chooseIndex === -1 ? 'ELSE' : 'CASE';
|
||||
return getAnchorId(nodeId, caseType, chooseIndex + 1);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<NodeIcon icon="mdi:code" color="#33c9ff" :stroke-width="0.5"/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import NodeIcon from "../base-node/NodeIcon.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<BaseNodeContainer v-bind="$props">
|
||||
<NodeKVS :kvs="nodeKVS"/>
|
||||
</BaseNodeContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {KVItemType} from "../base-node/const";
|
||||
import {computed} from "vue";
|
||||
import {BaseNodeContainer, NodeKVS} from "../base-node";
|
||||
import {useNode} from "../../hooks/useNode";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
},
|
||||
graph: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const {$properties, getInputParamKVItem, getOutputParamKVItem} = useNode(props)
|
||||
|
||||
const nodeKVS = computed<KVItemType[]>(() => {
|
||||
return [
|
||||
getInputParamKVItem({
|
||||
label: '输入变量',
|
||||
emptyAction: 'hidden',
|
||||
}),
|
||||
{
|
||||
label: '脚本类型',
|
||||
value: $properties.value?.options?.codeType,
|
||||
},
|
||||
{
|
||||
label: '脚本代码',
|
||||
value: $properties.value?.options?.code,
|
||||
emptyAction: 'tip',
|
||||
emptyTip: '尚未填写',
|
||||
},
|
||||
getOutputParamKVItem({
|
||||
label: '输出变量',
|
||||
emptyAction: 'hidden',
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- 代码节点设置 -->
|
||||
<template>
|
||||
<div class="code-setting">
|
||||
<div class="setting-item">
|
||||
<div class="label">输入变量</div>
|
||||
<VarListPicker v-model:vars="inputParams" :prevVariables="prevVariables"/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="code-header">
|
||||
<div class="code-type-select">
|
||||
<a-select v-model:value="codeType" :options="codeTypeOptions" size="small" style="width: 100%;" @change="handleCodeChange"/>
|
||||
</div>
|
||||
<a-tooltip title="帮助文档">
|
||||
<Icon icon="ant-design:question-circle" @click="handleHelpClick"/>
|
||||
</a-tooltip>
|
||||
<Icon icon="ant-design:fullscreen" @click="onCodeFullscreen" style="margin-left: 6px"/>
|
||||
</div>
|
||||
<a-divider style="margin: 4px 0 8px 0;"/>
|
||||
<CodeEditor :value="codeText" mode="javascript" @change="onCodeChange"/>
|
||||
<BasicModal
|
||||
@register="registerModal"
|
||||
:canFullscreen="false"
|
||||
:destroyOnClose="true"
|
||||
:defaultFullscreen="true"
|
||||
:footer="null"
|
||||
:header="null"
|
||||
>
|
||||
<div v-if="getOpen" style="padding: 10px 20px 0; width: 100%; height: 100%;">
|
||||
<CodeEditor :value="codeText" mode="javascript" @change="onCodeChange"/>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</div>
|
||||
<a-divider style="margin: 4px 0 8px 0;"/>
|
||||
<div class="setting-item">
|
||||
<div class="label">输出变量</div>
|
||||
<VarListEditor v-model:vars="outputParams"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useSettings} from "../../hooks/useSettings";
|
||||
import {CodeEditor} from '/@/components/CodeEditor';
|
||||
import {BasicModal, useModal} from "@/components/Modal";
|
||||
import {VarListPicker, VarListEditor} from "../../components/Vars";
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {type: String, required: true},
|
||||
node: {type: Object, required: true},
|
||||
properties: {type: Object, required: true},
|
||||
setProperties: {type: Function, required: true},
|
||||
})
|
||||
|
||||
const {inputParams, outputParams, prevVariables, createOptionRef} = useSettings(props)
|
||||
|
||||
const codeText = createOptionRef<string>('code')
|
||||
const codeType = createOptionRef<string>('codeType')
|
||||
|
||||
//初始化脚本代码
|
||||
const codeTypeList = {
|
||||
"javascript":"function main(params) {\n return {\n result: params.arg1 + '_拼接_' + params.arg2,\n }\n}",
|
||||
"python":'resp = {"result": u"{}_拼接_{}".format(params["arg1"], params["arg2"])}',
|
||||
"groovy":"def main(params) {\n" +
|
||||
" return [\n" +
|
||||
" result: \"${params.arg1}_拼接_${params.arg2}\"\n" +
|
||||
" ]\n" +
|
||||
"}",
|
||||
"kotlin":"fun main(params: Map<String, Any?>): Map<String, Any?> {\n" +
|
||||
" return mapOf(\n" +
|
||||
" \"result\" to \"${params[\"arg1\"]}_拼接_${params[\"arg2\"]}\"\n" +
|
||||
" )\n" +
|
||||
"}",
|
||||
"aviator":'let res = params.arg1 + "_拼接_" + params.arg2;\n' +
|
||||
'let resp = seq.map("result", res);'
|
||||
}
|
||||
|
||||
const codeTypeOptions = [
|
||||
{label: 'JavaScript', value: 'javascript'},
|
||||
// {label: 'Python', value: 'python'},
|
||||
{label: 'Groovy', value: 'groovy'},
|
||||
{label: 'Kotlin', value: 'kotlin'},
|
||||
{label: 'Aviator', value: 'aviator'},
|
||||
]
|
||||
|
||||
//帮助链接
|
||||
const helpPath = ref<string>("https://www.runoob.com/js/js-tutorial.html");
|
||||
|
||||
/**
|
||||
* 提示
|
||||
*/
|
||||
const tip = {
|
||||
python:"https://www.runoob.com/python/python-tutorial.html",
|
||||
javascript:"https://www.runoob.com/js/js-tutorial.html",
|
||||
groovy:"https://www.w3cschool.cn/groovy/",
|
||||
kotlin:"https://www.runoob.com/kotlin/kotlin-tutorial.html",
|
||||
aviator:"https://www.yuque.com/boyan-avfmj/aviatorscript",
|
||||
}
|
||||
|
||||
function onCodeChange(newCode: string) {
|
||||
codeText.value = newCode
|
||||
}
|
||||
|
||||
const [registerModal, {openModal, getOpen}] = useModal()
|
||||
|
||||
function onCodeFullscreen() {
|
||||
openModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* code值改变事件
|
||||
*/
|
||||
function handleCodeChange(value) {
|
||||
codeText.value = codeTypeList[value];
|
||||
helpPath.value = tip[value];
|
||||
}
|
||||
|
||||
/**
|
||||
* 帮助点击事件
|
||||
*/
|
||||
function handleHelpClick(){
|
||||
window.open(helpPath.value,"_blank");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.code-type-select {
|
||||
width: 100%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.app-iconify {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
import type {NodeConfig} from "../../types";
|
||||
import {NodeTypes} from "../../types";
|
||||
import LogicFlow from '@logicflow/core'
|
||||
|
||||
import {BaseNodeModel, BaseNodeView} from "../base-node";
|
||||
import {nodeDefWidth, nodeDefHeight} from "../base-node/const";
|
||||
import CodeIcon from './CodeIcon.vue'
|
||||
import CodeSetting from './CodeSetting.vue'
|
||||
import CodeNodeVue from './CodeNode.vue'
|
||||
|
||||
export class CodeNodeModel extends BaseNodeModel {
|
||||
|
||||
initNodeData(data: LogicFlow.NodeConfig) {
|
||||
super.initNodeData(data);
|
||||
if (!this.id.startsWith(NodeTypes.CODE + '_')) {
|
||||
this.id = NodeTypes.CODE + '_' + this.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config: NodeConfig = {
|
||||
type: NodeTypes.CODE,
|
||||
label: '脚本执行',
|
||||
docs: [
|
||||
'https://help.jeecg.com/aigc/flowNodes/nodeCode',
|
||||
],
|
||||
components: {
|
||||
icon: CodeIcon,
|
||||
setting: CodeSetting,
|
||||
},
|
||||
lfNode: {
|
||||
type: NodeTypes.CODE,
|
||||
view: BaseNodeView,
|
||||
model: CodeNodeModel,
|
||||
component: CodeNodeVue,
|
||||
},
|
||||
params: {
|
||||
width: nodeDefWidth,
|
||||
height: nodeDefHeight,
|
||||
},
|
||||
methods: {
|
||||
createNode,
|
||||
checkNode,
|
||||
initNodeData(this: CodeNodeModel) {
|
||||
if (!this.id.startsWith(NodeTypes.CODE + '_')) {
|
||||
this.id = NodeTypes.CODE + '_' + this.id;
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function createNode(data: Recordable) {
|
||||
return {
|
||||
id: data.id,
|
||||
remarks: '',
|
||||
type: config.type,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
properties: {
|
||||
text: data.text ?? config.label,
|
||||
options: {
|
||||
codeType: "javascript",
|
||||
code: `
|
||||
function main(params) {
|
||||
return {
|
||||
result: params.arg1 + '_拼接_' + params.arg2,
|
||||
}
|
||||
}
|
||||
`.trim(),
|
||||
},
|
||||
inputParams: [
|
||||
{field: '', name: 'arg1', nodeId: ''},
|
||||
{field: '', name: 'arg2', nodeId: ''},
|
||||
],
|
||||
outputParams: [
|
||||
{field: 'result', name: '返回结果', type: 'string'},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkNode(_lf: LogicFlow, node: any, {
|
||||
checkInputParams,
|
||||
}) {
|
||||
const {properties} = node;
|
||||
const {options} = properties;
|
||||
const problems: string[] = [
|
||||
...checkInputParams({text: '输入变量', required: false}),
|
||||
];
|
||||
if (!options.code) {
|
||||
problems.push('脚本内容不能为空');
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<NodeIcon icon="ic:round-stop" color="#ee4a4a"/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import NodeIcon from "../base-node/NodeIcon.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<BaseNodeContainer v-bind="$props">
|
||||
<NodeKVS :kvs="nodeKVS"/>
|
||||
</BaseNodeContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {KVItemType} from "../base-node/const";
|
||||
import {computed} from "vue";
|
||||
import {BaseNodeContainer} from "../base-node";
|
||||
import {NodeKVS} from "../base-node";
|
||||
import {useNode} from "../../hooks/useNode";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
},
|
||||
graph: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const {$properties, getOutputParamKVItem} = useNode(props)
|
||||
|
||||
const isOutputText = computed(() => {
|
||||
return $properties.value?.options?.outputText
|
||||
})
|
||||
|
||||
const nodeKVS = computed<KVItemType[]>(() => {
|
||||
const kvs = [
|
||||
getOutputParamKVItem({
|
||||
label: '输出变量',
|
||||
emptyAction: isOutputText.value ? 'hidden' : 'tip',
|
||||
}),
|
||||
{
|
||||
label: '输出格式',
|
||||
value: isOutputText.value ? '文本' : 'JSON',
|
||||
},
|
||||
]
|
||||
if (isOutputText.value) {
|
||||
kvs.push({
|
||||
label: '文本内容',
|
||||
value: $properties.value?.options?.outputContent,
|
||||
emptyTip: '尚未输入',
|
||||
emptyAction: isOutputText.value ? 'tip' : 'hidden',
|
||||
})
|
||||
}
|
||||
return kvs
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!-- 结束节点配置 -->
|
||||
<template>
|
||||
<div class="end-setting">
|
||||
<div class="setting-item">
|
||||
<div class="label">输出变量</div>
|
||||
<VarListPicker v-model:vars="outputParams" :prevVariables="prevVariables"/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">返回文本</div>
|
||||
<a-switch v-model:checked="outputText" checked-children="是" un-checked-children="否"/>
|
||||
</div>
|
||||
<div v-if="outputText" class="setting-item">
|
||||
<div class="label">文本内容</div>
|
||||
<VarTextarea
|
||||
v-model:value="outputContent"
|
||||
:varsOptions="outputVarsOptions"
|
||||
placeholder="请输入返回的文本内容。按下 “/” 可以选择变量"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useSettings} from "../../hooks/useSettings";
|
||||
import {VarListPicker, VarTextarea} from "../../components/Vars";
|
||||
|
||||
const props = defineProps({
|
||||
type: {type: String, required: true},
|
||||
node: {type: Object, required: true},
|
||||
properties: {type: Object, required: true},
|
||||
setProperties: {type: Function, required: true},
|
||||
})
|
||||
|
||||
const {prevVariables, outputParams, outputVarsOptions, createOptionRef} = useSettings(props)
|
||||
|
||||
const outputText = createOptionRef<boolean>('outputText');
|
||||
if (outputText.value == null) {
|
||||
outputText.value = false;
|
||||
}
|
||||
const outputContent = createOptionRef<string>('outputContent');
|
||||
if (outputContent.value == null) {
|
||||
outputContent.value = '';
|
||||
}
|
||||
|
||||
</script>
|
||||
<style scoped lang="less">
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {NodeConfig} from "../../types";
|
||||
import {NodeTypes} from "../../types";
|
||||
import LogicFlow from '@logicflow/core'
|
||||
|
||||
import {BaseNodeModel, BaseNodeView} from "../base-node";
|
||||
import {nodeDefWidth, nodeDefHeight} from "../base-node/const";
|
||||
import EndIcon from './EndIcon.vue'
|
||||
import EndSetting from './EndSetting.vue'
|
||||
import EndNodeVue from './EndNode.vue'
|
||||
|
||||
const config: NodeConfig = {
|
||||
type: NodeTypes.END,
|
||||
label: '结束',
|
||||
docs: [
|
||||
'https://help.jeecg.com/aigc/flowNodes/nodeEnd',
|
||||
],
|
||||
components: {
|
||||
icon: EndIcon,
|
||||
setting: EndSetting,
|
||||
},
|
||||
lfNode: {
|
||||
type: NodeTypes.END,
|
||||
view: BaseNodeView,
|
||||
model: BaseNodeModel,
|
||||
component: EndNodeVue
|
||||
},
|
||||
params: {
|
||||
width: nodeDefWidth,
|
||||
height: nodeDefHeight,
|
||||
},
|
||||
methods: {
|
||||
createNode,
|
||||
checkNode,
|
||||
|
||||
getDefaultAnchor(defaultAnchor) {
|
||||
// 只保留左侧锚点
|
||||
return defaultAnchor.filter((item) => item.type === 'left');
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function createNode(data: Recordable) {
|
||||
return {
|
||||
id: data.id,
|
||||
remarks: '',
|
||||
type: config.type,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
properties: {
|
||||
text: config.label,
|
||||
options: {
|
||||
outputText: false,
|
||||
outputContent: '',
|
||||
},
|
||||
inputParams: [],
|
||||
outputParams: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkNode(_lf: LogicFlow, node: any, {
|
||||
checkOutputParams,
|
||||
}) {
|
||||
const {properties} = node;
|
||||
const {options} = properties;
|
||||
|
||||
const isOutputText = options.outputText
|
||||
const outputContent = options.outputContent
|
||||
|
||||
const problems: string[] = [
|
||||
...checkOutputParams({text: '输出变量', required: !isOutputText}),
|
||||
];
|
||||
|
||||
if (isOutputText && !outputContent) {
|
||||
problems.push('返回文本不能为空');
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<NodeIcon icon="ant-design:java-outlined" color="#33c9ff" :stroke-width="32"/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import NodeIcon from "../base-node/NodeIcon.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<BaseNodeContainer v-bind="$props">
|
||||
<NodeKVS :kvs="nodeKVS"/>
|
||||
</BaseNodeContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {KVItemType} from "../base-node/const";
|
||||
import {computed} from "vue";
|
||||
import {BaseNodeContainer, NodeKVS} from "../base-node";
|
||||
import {useNode} from "../../hooks/useNode";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
},
|
||||
graph: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const {$properties, getInputParamKVItem, getOutputParamKVItem} = useNode(props)
|
||||
|
||||
const nodeKVS = computed<KVItemType[]>(() => {
|
||||
const isClass = $properties.value?.options?.enhance?.type === 'class'
|
||||
return [
|
||||
getInputParamKVItem({
|
||||
label: '输入变量',
|
||||
emptyAction: 'hidden',
|
||||
}),
|
||||
{
|
||||
label: '增强类型',
|
||||
value: isClass ? '类路径' : 'Spring Bean',
|
||||
},
|
||||
{
|
||||
label: isClass ? '类路径' : 'Bean 名称',
|
||||
value: $properties.value?.options?.enhance?.path,
|
||||
emptyAction: 'tip',
|
||||
emptyTip: '尚未填写',
|
||||
width: isClass ? void 0 : 64,
|
||||
},
|
||||
getOutputParamKVItem({
|
||||
label: '输出变量',
|
||||
emptyAction: 'hidden',
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<!-- Java增强设置 -->
|
||||
<template>
|
||||
<div class="enhance-java-setting">
|
||||
<div class="setting-item">
|
||||
<div class="label">输入变量</div>
|
||||
<VarListPicker v-model:vars="inputParams" :prevVariables="prevVariables"/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">增强类型</div>
|
||||
<a-radio-group v-model:value="enhanceType" button-style="solid">
|
||||
<a-radio-button value="class">类路径</a-radio-button>
|
||||
<a-radio-button value="spring">Spring Bean</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
<span v-if="enhanceType === 'class'">类路径</span>
|
||||
<span v-else>Spring Bean 名称</span>
|
||||
</div>
|
||||
<a-input
|
||||
v-model:value="enhancePath"
|
||||
:placeholder="'请输入' + (enhanceType === 'class' ? '类路径' : 'Spring Bean 名称')"
|
||||
/>
|
||||
<p class="p-tip">
|
||||
<span>需要实现 IAiRagEnhanceJava 接口</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">输出变量</div>
|
||||
<VarListEditor v-model:vars="outputParams"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useSettings} from "../../hooks/useSettings";
|
||||
import {VarListPicker, VarListEditor} from "../../components/Vars";
|
||||
|
||||
const props = defineProps({
|
||||
type: {type: String, required: true},
|
||||
node: {type: Object, required: true},
|
||||
properties: {type: Object, required: true},
|
||||
setProperties: {type: Function, required: true},
|
||||
})
|
||||
|
||||
const {inputParams, outputParams, prevVariables, createOptionRef} = useSettings(props)
|
||||
|
||||
const enhanceType = createOptionRef<'class' | 'spring'>('enhance.type')
|
||||
const enhancePath = createOptionRef<string>('enhance.path')
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.code-header {
|
||||
text-align: right;
|
||||
|
||||
.app-iconify {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {NodeConfig} from "../../types";
|
||||
import {NodeTypes} from "../../types";
|
||||
import LogicFlow from '@logicflow/core'
|
||||
|
||||
import {BaseNodeModel, BaseNodeView} from "../base-node";
|
||||
import {nodeDefWidth, nodeDefHeight} from "../base-node/const";
|
||||
import EnhanceJavaIcon from './EnhanceJavaIcon.vue'
|
||||
import EnhanceJavaSetting from './EnhanceJavaSetting.vue'
|
||||
import EnhanceJavaNodeVue from './EnhanceJavaNode.vue'
|
||||
|
||||
const config: NodeConfig = {
|
||||
type: NodeTypes.ENHANCE_JAVA,
|
||||
label: 'Java 增强',
|
||||
docs: [
|
||||
'https://help.jeecg.com/aigc/flowNodes/nodeJava',
|
||||
],
|
||||
components: {
|
||||
icon: EnhanceJavaIcon,
|
||||
setting: EnhanceJavaSetting,
|
||||
},
|
||||
lfNode: {
|
||||
type: NodeTypes.ENHANCE_JAVA,
|
||||
view: BaseNodeView,
|
||||
model: BaseNodeModel,
|
||||
component: EnhanceJavaNodeVue,
|
||||
},
|
||||
params: {
|
||||
width: nodeDefWidth,
|
||||
height: nodeDefHeight,
|
||||
},
|
||||
methods: {
|
||||
createNode,
|
||||
checkNode,
|
||||
},
|
||||
}
|
||||
|
||||
function createNode(data: Recordable) {
|
||||
return {
|
||||
id: data.id,
|
||||
remarks: '',
|
||||
type: config.type,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
properties: {
|
||||
text: data.text ?? config.label,
|
||||
options: {
|
||||
enhance: {
|
||||
type: 'class',
|
||||
path: ''
|
||||
}
|
||||
},
|
||||
inputParams: [
|
||||
{field: '', name: 'arg1', nodeId: ''},
|
||||
{field: '', name: 'arg2', nodeId: ''},
|
||||
],
|
||||
outputParams: [
|
||||
{field: 'result', name: '返回结果', type: 'string'},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkNode(_lf: LogicFlow, node: any, {
|
||||
checkInputParams,
|
||||
}) {
|
||||
const {properties} = node;
|
||||
const {options} = properties;
|
||||
const problems: string[] = [
|
||||
...checkInputParams({text: '输入变量', required: false}),
|
||||
];
|
||||
const isClass = options.enhance.type === 'class';
|
||||
if (!options.enhance.path) {
|
||||
problems.push(`${isClass ? '类路径' : 'Spring Bean 名称'}必须填写`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<NodeIcon icon="ic:round-http" color="#33c9ff">
|
||||
<template #icon>
|
||||
<svg
|
||||
class="http-icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="1620"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<path
|
||||
fill="#ffffff"
|
||||
d="M400.896 704.292571v194.889143a376.795429 376.795429 0 0 1-84.845714-12.8c-21.028571-34.486857-48.384-93.732571-65.682286-182.089143h150.528z m448 8.521143v38.070857H803.108571V885.028571h-41.984v-134.144h-89.929142V885.028571h-41.984v-134.144h-45.860572v-38.034285h265.508572z m-368.347429 0v63.561143h50.395429V712.777143h41.984V885.028571h-41.984v-70.582857H480.548571v70.582857h-41.947428V712.777143h41.947428z m433.188572 0c19.456 0 32.146286 0.841143 38.509714 2.633143 10.057143 2.742857 18.505143 8.740571 25.161143 17.810286 6.729143 9.069714 10.020571 20.662857 10.020571 34.596571 0 10.715429-1.901714 19.894857-5.741714 27.428572a49.115429 49.115429 0 0 1-33.024 26.404571c-7.899429 1.645714-19.017143 2.450286-33.462857 2.450286h-14.994286V885.028571h-41.947428V712.777143h55.478857z m-691.602286-8.521143c15.579429 79.798857 39.460571 135.606857 59.465143 171.154286a377.526857 377.526857 0 0 1-198.144-171.154286h138.678857z m693.248 35.84h-25.929143v56.576h22.491429c12.946286 0 21.686857-0.914286 26.697143-2.925714a26.038857 26.038857 0 0 0 12.214857-9.984 27.757714 27.757714 0 0 0 4.388571-15.506286 26.733714 26.733714 0 0 0-6.217143-18.029714 26.697143 26.697143 0 0 0-15.652571-8.923428 114.505143 114.505143 0 0 0-17.993143-1.170286z m-514.486857-203.410285v139.629714h-155.428571a957.586286 957.586286 0 0 1-11.702858-139.629714h167.131429z m-195.108571 0a955.977143 955.977143 0 0 0 11.776 139.629714H70.107429A372.626286 372.626286 0 0 1 36.571429 536.722286h169.216z m390.217142 0a957.696 957.696 0 0 1-11.702857 139.629714h-155.428571v-139.629714h167.131428z m193.170286 0a374.930286 374.930286 0 0 1-33.389714 139.629714H612.205714a956.342857 956.342857 0 0 0 11.776-139.629714h165.193143z m-32.548571-167.570286c19.346286 42.934857 30.902857 89.965714 32.841143 139.629714h-165.376c-0.694857-52.48-5.010286-98.742857-11.556572-139.629714h144.091429z m-355.730286 0v139.629714H233.581714c0.731429-52.516571 5.12-98.742857 11.629715-139.629714h155.684571z m-183.661714 0a965.412571 965.412571 0 0 0-11.593143 139.629714H36.790857c1.828571-49.627429 13.019429-96.768 32.256-139.629714H217.234286z m367.323428 0c6.546286 40.923429 10.898286 87.113143 11.629715 139.629714h-167.350858v-139.629714h155.721143z m-34.998857-197.595429c82.651429 32.109714 150.528 92.891429 193.060572 169.691429H607.817143c-15.286857-78.189714-38.509714-133.741714-58.258286-169.691429zM428.873143 146.285714c29.696 1.316571 58.514286 5.997714 86.125714 13.750857 20.955429 35.181714 47.908571 93.952 64.987429 181.174858h-151.149715V146.285714z m-27.977143 0.036572v194.925714H249.819429c17.115429-87.771429 44.324571-146.724571 65.316571-181.833143A376.758857 376.758857 0 0 1 400.896 146.285714z m-120.100571 24.064c-19.858286 35.949714-43.410286 91.794286-58.88 170.825143H83.053714a378.404571 378.404571 0 0 1 197.741715-170.788572z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
</NodeIcon>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import NodeIcon from "../base-node/NodeIcon.vue";
|
||||
</script>
|
||||
<style lang="less">
|
||||
//noinspection LessUnresolvedVariable
|
||||
@prefix-cls: ~'@{namespace}-airag-base-node-icon';
|
||||
|
||||
.@{prefix-cls} {
|
||||
.http-icon {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
left: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<BaseNodeContainer v-bind="$props">
|
||||
<NodeKVS :kvs="nodeKVS"/>
|
||||
</BaseNodeContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {KVItemType} from "../base-node/const";
|
||||
import {computed} from "vue";
|
||||
import {BaseNodeContainer, NodeKVS} from "../base-node";
|
||||
import {useNode} from "../../hooks/useNode";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
},
|
||||
graph: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const {$properties, getInputParamKVItem, getOutputParamKVItem} = useNode(props)
|
||||
|
||||
const nodeKVS = computed<KVItemType[]>(() => {
|
||||
const method = $properties.value?.options?.http?.method
|
||||
const url = $properties.value?.options?.http?.url
|
||||
const apiText = !url ? '' : `[${method}] ${url}`
|
||||
|
||||
const requestParams = $properties.value?.options?.http?.requestParams
|
||||
const requestParamsText = requestParams ? Object.keys(requestParams).join(', ') : ''
|
||||
|
||||
const headers = $properties.value?.options?.http?.headers
|
||||
const headersText = headers ? Object.keys(headers).join(', ') : ''
|
||||
|
||||
const requestBodyText = $properties.value?.options?.http?.requestBody?.body
|
||||
const requestBodyType = $properties.value?.options?.http?.requestBody?.type
|
||||
const requestBodyTypeText = requestBodyType !== 'none' && requestBodyText ? requestBodyType : ''
|
||||
|
||||
return [
|
||||
getInputParamKVItem({
|
||||
label: '输入变量',
|
||||
emptyAction: 'hidden',
|
||||
}),
|
||||
{
|
||||
label: 'API',
|
||||
value: apiText,
|
||||
emptyAction: 'tip',
|
||||
emptyTip: '尚未填写',
|
||||
},
|
||||
{label: '请求参数', value: requestParamsText},
|
||||
{label: '请求头', value: headersText},
|
||||
{label: '请求体类型', value: requestBodyTypeText},
|
||||
{label: '请求体内容', value: requestBodyText},
|
||||
getOutputParamKVItem({label: '输出变量',}),
|
||||
]
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!-- HTTP配置 -->
|
||||
<template>
|
||||
<div class="http-setting">
|
||||
<div class="setting-item">
|
||||
<div class="label">输入变量</div>
|
||||
<VarListPicker v-model:vars="inputParams" :prevVariables="prevVariables"/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">API</div>
|
||||
<div class="api-setting">
|
||||
<div class="method-select">
|
||||
<a-select v-model:value="method" style="width: 120px">
|
||||
<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>
|
||||
</div>
|
||||
<div class="url-input">
|
||||
<VarTextarea
|
||||
class="input-element"
|
||||
type="input"
|
||||
v-model:value="url"
|
||||
:varsOptions="inputVarsOptions"
|
||||
placeholder="请输入API地址。按下 “/” 可以选择变量"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">请求参数</div>
|
||||
<VarEditable :columns="columns" v-model:data="paramsData" :varsOptions="inputVarsOptions"/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">请求头</div>
|
||||
<VarEditable v-if="showTable" :columns="columns" v-model:data="headersData" :varsOptions="inputVarsOptions"/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">请求体</div>
|
||||
<a-select v-model:value="requestBodyType" style="width: 100%; margin-bottom: 8px;">
|
||||
<a-select-option value="none">none</a-select-option>
|
||||
<a-select-option value="json">JSON</a-select-option>
|
||||
<a-select-option value="form-data">form-data</a-select-option>
|
||||
<a-select-option value="x-www-form-urlencoded">x-www-form-urlencoded</a-select-option>
|
||||
<a-select-option value="raw">raw</a-select-option>
|
||||
<!-- <a-select-option value="binary">binary</a-select-option> -->
|
||||
</a-select>
|
||||
<VarTextarea
|
||||
v-model:value="requestBody"
|
||||
:varsOptions="inputVarsOptions"
|
||||
:height="120"
|
||||
placeholder="请输入请求体。按下 “/” 可以选择变量"
|
||||
/>
|
||||
</div>
|
||||
<!-- <div class="setting-item">-->
|
||||
<!-- <div class="label">超时时间</div>-->
|
||||
<!-- <a-space>-->
|
||||
<!-- <a-input-number v-model:value="timeout" :min="0" placeholder="请输入超时时间"/>-->
|
||||
<!-- <span>秒</span>-->
|
||||
<!-- </a-space>-->
|
||||
<!-- </div>-->
|
||||
<div class="setting-item">
|
||||
<div class="label">输出变量</div>
|
||||
<VarListEditor v-model:vars="outputParams" :fixedVars="outputFixedVars" fieldBeforeText="body."/>
|
||||
<!-- <VarListShow :vars="outputParams"/>-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {Ref} from 'vue';
|
||||
import {ref, computed, unref} from 'vue';
|
||||
import {useSettings} from '../../hooks/useSettings';
|
||||
import {VarListPicker, VarEditable, VarTextarea, VarListEditor} from '../../components/Vars';
|
||||
|
||||
const props = defineProps({
|
||||
type: {type: String, required: true},
|
||||
node: {type: Object, required: true},
|
||||
properties: {type: Object, required: true},
|
||||
setProperties: {type: Function, required: true},
|
||||
});
|
||||
|
||||
const {inputParams, inputVarsOptions, outputParams, prevVariables, createOptionRef} = useSettings(props);
|
||||
|
||||
const url = createOptionRef<string>('http.url');
|
||||
const method = createOptionRef<string>('http.method');
|
||||
const headers = createOptionRef<Recordable>('http.headers');
|
||||
const requestBody = createOptionRef<string>('http.requestBody.body');
|
||||
const requestBodyType = createOptionRef<string>('http.requestBody.type');
|
||||
const requestParams = createOptionRef<Recordable>('http.requestParams');
|
||||
|
||||
// const timeout = createOptionRef<number>('http.timeout');
|
||||
|
||||
const columns = ref<any[]>([
|
||||
{field: 'name', label: '参数名', type: 'input', required: true},
|
||||
{field: 'value', label: '参数值', type: 'var-input', required: true},
|
||||
]);
|
||||
|
||||
const headersData = createObjectDataRef(headers);
|
||||
const paramsData = createObjectDataRef(requestParams);
|
||||
|
||||
const showTable = ref(true);
|
||||
|
||||
// 创建对象数据引用
|
||||
function createObjectDataRef(obj: Ref<Recordable>) {
|
||||
return computed({
|
||||
get() {
|
||||
return Object.entries(unref(obj)).map(([key, value]) => ({
|
||||
name: key,
|
||||
value: value,
|
||||
}));
|
||||
},
|
||||
set(value: Recordable[]) {
|
||||
const newObj = {};
|
||||
value.forEach((item) => {
|
||||
newObj[item.name] = item.value;
|
||||
});
|
||||
obj.value = newObj;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const outputFixedVars = {
|
||||
body: {
|
||||
tip: 'HTTP请求的返回结果',
|
||||
},
|
||||
statusCode: {
|
||||
tip: 'HTTP请求的返回状态码',
|
||||
},
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.http-setting {
|
||||
.api-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.method-select {
|
||||
flex: 0 0 120px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
.input-element {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {NodeConfig} from "../../types";
|
||||
import {NodeTypes} from "../../types";
|
||||
import LogicFlow from '@logicflow/core'
|
||||
|
||||
import {useUpdateSettings} from "../../hooks/useSettings";
|
||||
import {BaseNodeModel, BaseNodeView} from "../base-node";
|
||||
import {nodeDefWidth, nodeDefHeight} from "../base-node/const";
|
||||
import HTTPIcon from './HTTPIcon.vue'
|
||||
import HTTPSetting from './HTTPSetting.vue'
|
||||
import HTTPNodeVue from './HTTPNode.vue'
|
||||
|
||||
const config: NodeConfig = {
|
||||
type: NodeTypes.HTTP,
|
||||
label: "HTTP 请求",
|
||||
docs: [
|
||||
'https://help.jeecg.com/aigc/flowNodes/nodeHttp',
|
||||
],
|
||||
components: {
|
||||
icon: HTTPIcon,
|
||||
setting: HTTPSetting,
|
||||
},
|
||||
lfNode: {
|
||||
type: NodeTypes.HTTP,
|
||||
view: BaseNodeView,
|
||||
model: BaseNodeModel,
|
||||
component: HTTPNodeVue,
|
||||
},
|
||||
params: {
|
||||
width: nodeDefWidth,
|
||||
height: nodeDefHeight,
|
||||
},
|
||||
methods: {
|
||||
createNode,
|
||||
checkNode,
|
||||
updateNodeSetting,
|
||||
},
|
||||
}
|
||||
|
||||
const getDefProp = () => {
|
||||
return {
|
||||
inputParams: [],
|
||||
outputParams: [
|
||||
{field: 'body', name: '回复内容', type: 'string'},
|
||||
{field: 'statusCode', name: '状态码', type: 'number'},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function createNode(data: Recordable) {
|
||||
const defProp = getDefProp();
|
||||
return {
|
||||
id: data.id,
|
||||
remarks: '',
|
||||
type: config.type,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
properties: {
|
||||
text: data.text ?? config.label,
|
||||
options: {
|
||||
http: {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
requestBody: {
|
||||
// json, form-data, x-www-form-urlencoded, raw, binary, none
|
||||
type: 'none',
|
||||
body: '',
|
||||
},
|
||||
requestParams: {},
|
||||
timeout: 120,
|
||||
}
|
||||
},
|
||||
inputParams: defProp.inputParams,
|
||||
outputParams: defProp.outputParams,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateNodeSetting(node: Recordable) {
|
||||
const {mergeIOParams} = useUpdateSettings(node, getDefProp);
|
||||
|
||||
mergeIOParams();
|
||||
|
||||
const {requestBody} = node.properties.options.http;
|
||||
if (!requestBody || typeof requestBody === 'string') {
|
||||
node.properties.options.http.requestBody = {
|
||||
type: 'none',
|
||||
body: requestBody || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkNode(_lf: LogicFlow, node: any, {
|
||||
checkInputParams,
|
||||
}) {
|
||||
const {properties} = node;
|
||||
const {options} = properties;
|
||||
const problems: string[] = [
|
||||
...checkInputParams({text: '输入变量', required: false}),
|
||||
];
|
||||
if (!options.http.url) {
|
||||
problems.push('请求地址必须填写');
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,109 @@
|
||||
import type {NodeConfig} from '../types'
|
||||
import {NodeTypes} from '../types'
|
||||
import {register as vueNodeRegister} from '@logicflow/vue-node-registry'
|
||||
import LogicFlow from '@logicflow/core'
|
||||
import {NodeConfigMap, NodeIconMap, NodeSettingMap, NodeTypeOrder} from '../const'
|
||||
import {BaseEdge} from "./base-node"
|
||||
import StartNode from './start-node'
|
||||
import EndNode from './end-node'
|
||||
import LLMNode from './llm-node'
|
||||
import ClassifierNode from './classifier-node'
|
||||
import SwitchNode from './switch-node'
|
||||
import KnowledgeNode from './knowledge-node'
|
||||
import CodeNode from './code-node'
|
||||
import SubflowNode from './subflow-node'
|
||||
import EnhanceJavaNode from './enhance-java-node'
|
||||
import HTTPNode from './http-node'
|
||||
import ReplyNode from './reply-node'
|
||||
|
||||
export const DefaultEdgeType = BaseEdge.type
|
||||
|
||||
/**
|
||||
* 注册所有的vue节点
|
||||
*/
|
||||
export function registerAllVueNode(lf: LogicFlow) {
|
||||
// 0. 清理
|
||||
NodeIconMap.clear();
|
||||
NodeSettingMap.clear();
|
||||
NodeConfigMap.clear();
|
||||
NodeTypeOrder.clear();
|
||||
// 1. 注册自定义边
|
||||
lf.register(BaseEdge)
|
||||
// 2. 注册自定义节点
|
||||
const register = getRegisterFn(lf);
|
||||
// 开始节点
|
||||
register(StartNode, true);
|
||||
|
||||
// LLM节点
|
||||
register(LLMNode);
|
||||
// 分类器节点
|
||||
register(ClassifierNode);
|
||||
// -------
|
||||
NodeTypeOrder.addDivider();
|
||||
// 知识库节点
|
||||
register(KnowledgeNode);
|
||||
// -------
|
||||
NodeTypeOrder.addDivider();
|
||||
// 条件分支节点
|
||||
register(SwitchNode);
|
||||
// 脚本节点
|
||||
register(CodeNode);
|
||||
// Java增强节点
|
||||
register(EnhanceJavaNode);
|
||||
// HTTP节点
|
||||
register(HTTPNode);
|
||||
// -------
|
||||
NodeTypeOrder.addDivider();
|
||||
// 子流程节点
|
||||
register(SubflowNode);
|
||||
// -------
|
||||
NodeTypeOrder.addDivider();
|
||||
// 直接回复节点
|
||||
register(ReplyNode);
|
||||
// 结束节点
|
||||
register(EndNode);
|
||||
}
|
||||
|
||||
function getRegisterFn(lf: LogicFlow) {
|
||||
return (vn: NodeConfig, noAdd = false) => {
|
||||
vn.docs = handleNodeDocs(vn);
|
||||
NodeConfigMap.set(vn.type, vn);
|
||||
vueNodeRegister(vn.lfNode, lf);
|
||||
// 添加图标
|
||||
if (vn.components.icon) {
|
||||
NodeIconMap.set(vn.type, vn.components.icon);
|
||||
}
|
||||
// 添加配置
|
||||
if (vn.components.setting) {
|
||||
NodeSettingMap.set(vn.type, vn.components.setting);
|
||||
}
|
||||
if (!noAdd) {
|
||||
NodeTypeOrder.add(vn.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeDocs(node: NodeConfig): string | undefined {
|
||||
const {docs} = node;
|
||||
if (!docs) {
|
||||
return;
|
||||
}
|
||||
if (typeof docs === 'string') {
|
||||
return docs;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (!Array.isArray(docs) || docs.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (docs.length > 1) {
|
||||
// TODO 判断是否是敲敲云环境
|
||||
return docs[1];
|
||||
}
|
||||
return docs[0];
|
||||
}
|
||||
|
||||
export {BaseNodeModel} from './base-node'
|
||||
|
||||
export {
|
||||
NodeTypes
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<NodeIcon icon="garden:knowledge-base-26" color="#4165d7"/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import NodeIcon from "../base-node/NodeIcon.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<BaseNodeContainer v-bind="$props">
|
||||
<NodeKVS :kvs="nodeKVS"/>
|
||||
</BaseNodeContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {KVItemType} from "../base-node/const";
|
||||
import {computed, watch} from "vue";
|
||||
import {BaseNodeContainer} from "../base-node";
|
||||
import {NodeKVS} from "../base-node";
|
||||
import {NodeTypes} from "../../types";
|
||||
import {useNode} from "../../hooks/useNode";
|
||||
import {queryKnowledgeDataList} from "./data";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
},
|
||||
graph: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const {$properties, createStoreRef, prevNodes, inputParams, getOutputParamKVItem} = useNode(props)
|
||||
|
||||
// 当前节点的知识库数据
|
||||
const knowledgeDataList = createStoreRef<any[]>('knowledgeDataList');
|
||||
|
||||
watch(() => $properties.value?.options?.knowIds, async (val) => {
|
||||
queryKnowledgeDataList(val, knowledgeDataList);
|
||||
}, {immediate: true, deep: true})
|
||||
|
||||
// 输入参数
|
||||
const inputParamText = computed(() => {
|
||||
if (inputParams.value.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const param = inputParams.value[0]
|
||||
if (!param.field || !param.nodeId) {
|
||||
return ''
|
||||
}
|
||||
const pNode = prevNodes.value.find((pNode) => pNode.id === param.nodeId)
|
||||
if (!pNode) {
|
||||
return ''
|
||||
}
|
||||
let pParams = pNode.properties.outputParams
|
||||
// 开始节点特殊处理
|
||||
if (pNode.type === NodeTypes.START) {
|
||||
pParams = pNode.properties.inputParams
|
||||
}
|
||||
const prevNodeParam = pParams.find((item) => item.field === param.field)
|
||||
if (!prevNodeParam) {
|
||||
return ''
|
||||
}
|
||||
return `${pNode.properties.text} / ${prevNodeParam.name}`
|
||||
})
|
||||
|
||||
// 知识库文本
|
||||
const knowText = computed(() => {
|
||||
if (!knowledgeDataList.value?.length) {
|
||||
return '';
|
||||
}
|
||||
return knowledgeDataList.value.map((item) => item.name).join(', ')
|
||||
})
|
||||
|
||||
const nodeKVS = computed<KVItemType[]>(() => {
|
||||
return [
|
||||
{
|
||||
label: '查询变量',
|
||||
value: inputParamText.value,
|
||||
emptyAction: 'tip',
|
||||
emptyTip: '尚未选择',
|
||||
},
|
||||
{
|
||||
label: '知识库',
|
||||
value: knowText.value,
|
||||
emptyAction: 'tip',
|
||||
emptyTip: '尚未选择',
|
||||
},
|
||||
getOutputParamKVItem(),
|
||||
]
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<!-- 知识库配置 -->
|
||||
<template>
|
||||
<div class="knowledge-setting">
|
||||
<div class="setting-item">
|
||||
<div class="label">查询变量</div>
|
||||
<VarPicker :vars="prevVariables" :item="searchVar" @change="($node) => updateSearchVar($node)"/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div style="display: flex; justify-content: space-between; width: 100%">
|
||||
<div class="label">知识库</div>
|
||||
<div class="setting-item-icon">
|
||||
<span @click="handleAddKnowledgeClick" class="knowledge-txt pointer">
|
||||
<Icon icon="ant-design:plus-outlined" size="13" style="margin-right: 4px"></Icon>添加
|
||||
</span>
|
||||
<a-popover trigger="click" placement="bottomRight">
|
||||
<template #content>
|
||||
<div class="setting-item">
|
||||
<div class="label">Top K</div>
|
||||
<a-space>
|
||||
<a-input-number v-model:value="topNumber" :min="1" :max="10" size="small" style="width: 80px"/>
|
||||
<a-slider v-model:value="topNumber" :min="1" :max="10" size="small" style="width: 240px"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
<a-space>
|
||||
<a-switch v-model:checked="enableSimilarity" size="small"/>
|
||||
<div>Score 阈值</div>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-input-number v-model:value="similarity" v-bind="similarityProps" size="small" style="width: 80px"/>
|
||||
<a-slider v-model:value="similarity" v-bind="similarityProps" size="small" style="width: 240px"/>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
<a-tooltip title="参数配置">
|
||||
<Icon icon="ant-design:setting" class="pointer"/>
|
||||
</a-tooltip>
|
||||
</a-popover>
|
||||
</div>
|
||||
</div>
|
||||
<a-row :span="24">
|
||||
<a-col :span="12" v-for="item in knowledgeDataList" v-if="knowledgeDataList && knowledgeDataList.length>0">
|
||||
<a-card hoverable class="knowledge-card" :body-style="{ width: '100%' }">
|
||||
<div style="display: flex; width: 100%; justify-content: space-between">
|
||||
<div>
|
||||
<img class="knowledge-img" :src="knowledge"/>
|
||||
<span class="knowledge-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<Icon @click="handleDeleteKnowledge(item.id)" icon="ant-design:close-outlined" size="20"
|
||||
class="knowledge-icon"></Icon>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">输出变量</div>
|
||||
<VarListShow :vars="outputParams"/>
|
||||
</div>
|
||||
<!-- Ai知识库选择弹窗 -->
|
||||
<AiAppAddKnowledgeModal @register="registerKnowledgeModal" @success="handleSuccess"></AiAppAddKnowledgeModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue";
|
||||
import {cloneDeep} from "lodash-es";
|
||||
import {useModal} from "@/components/Modal";
|
||||
import knowledge from '../../../aiknowledge/icon/knowledge.png';
|
||||
import AiAppAddKnowledgeModal from "../../../aiapp/components/AiAppAddKnowledgeModal.vue";
|
||||
import {queryKnowledgeDataList} from "./data";
|
||||
import {useSettings} from "../../hooks/useSettings";
|
||||
import {VarListShow, VarPicker} from "../../components/Vars";
|
||||
|
||||
const props = defineProps({
|
||||
type: {type: String, required: true},
|
||||
node: {type: Object, required: true},
|
||||
properties: {type: Object, required: true},
|
||||
setProperties: {type: Function, required: true},
|
||||
})
|
||||
|
||||
const {inputParams, outputParams, prevVariables, createOptionRef, createStoreRef} = useSettings(props)
|
||||
|
||||
const knowIds = createOptionRef<any>('knowIds')
|
||||
const topNumber = createOptionRef<number>('topNumber')
|
||||
|
||||
/*// 知识库下拉选项
|
||||
const knowOptions = ref<Recordable[]>([])
|
||||
|
||||
function loadKnowOptions() {
|
||||
const apiUrl = `/sys/dict/getDictItems/airag_knowledge%20where%20status%20=%20'enable',name,id`;
|
||||
defHttp.get({url: apiUrl}).then((res) => {
|
||||
knowOptions.value = res
|
||||
});
|
||||
}
|
||||
|
||||
loadKnowOptions();
|
||||
|
||||
function onKnowChange(value: string) {
|
||||
knowIds.value = value
|
||||
}*/
|
||||
|
||||
// 查询变量
|
||||
const searchVar = computed({
|
||||
get(): any {
|
||||
if (!inputParams.value[0]) {
|
||||
return {field: '', nodeId: ''};
|
||||
}
|
||||
return inputParams.value[0];
|
||||
},
|
||||
set(varItem: any) {
|
||||
inputParams.value = [varItem]
|
||||
},
|
||||
})
|
||||
|
||||
// 更新查询变量
|
||||
function updateSearchVar(node: Recordable) {
|
||||
if (!node?.nodeId) {
|
||||
searchVar.value = {field: '', nodeId: ''};
|
||||
} else {
|
||||
searchVar.value = {field: node.field, nodeId: node.nodeId}
|
||||
}
|
||||
}
|
||||
|
||||
// Score 阈值
|
||||
const similarity = createOptionRef<number | null>('similarity');
|
||||
// 是否开启 Score 阈值
|
||||
const enableSimilarity = computed({
|
||||
get(): boolean {
|
||||
return similarity.value !== null;
|
||||
},
|
||||
set(val: boolean) {
|
||||
similarity.value = val ? 0.75 : null;
|
||||
}
|
||||
})
|
||||
const similarityProps = computed(() => ({
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
disabled: !enableSimilarity.value
|
||||
}))
|
||||
|
||||
// ================================== begin 知识库选择使用AI应用中的知识库选择弹框 ====================================================
|
||||
//注册modal
|
||||
const [registerKnowledgeModal, {openModal}] = useModal();
|
||||
|
||||
//知识库集合
|
||||
const knowledgeDataList = createStoreRef<Recordable[]>('knowledgeDataList');
|
||||
|
||||
/**
|
||||
* 添加知识库
|
||||
*/
|
||||
function handleAddKnowledgeClick() {
|
||||
openModal(true, {
|
||||
knowledgeIds: knowIds.value.join(","),
|
||||
knowledgeDataList: knowledgeDataList.value,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中回调事件
|
||||
* @param knowledgeId
|
||||
* @param knowledgeData
|
||||
*/
|
||||
function handleSuccess(knowledgeId, knowledgeData) {
|
||||
knowIds.value = cloneDeep(knowledgeId);
|
||||
knowledgeDataList.value = cloneDeep(knowledgeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除知识库
|
||||
* @param id
|
||||
*/
|
||||
function handleDeleteKnowledge(id) {
|
||||
let array = knowIds.value;
|
||||
let findIndex = array.findIndex((item) => item === id);
|
||||
if (findIndex != -1) {
|
||||
array.splice(findIndex, 1);
|
||||
knowIds.value = array;
|
||||
knowledgeDataList.value.splice(findIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
queryKnowledgeDataList(knowIds, knowledgeDataList);
|
||||
|
||||
// ================================== end 知识库选择使用AI应用中的知识库选择弹框 ====================================================
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.knowledge-txt {
|
||||
color: #354052;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
font-size: 12px
|
||||
}
|
||||
|
||||
.setting-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.knowledge-img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.knowledge-name {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.knowledge-icon {
|
||||
display: none !important;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {Ref} from "vue";
|
||||
import {unref} from "vue";
|
||||
import {queryKnowledgeBathById} from "../../../aiapp/AiApp.api";
|
||||
|
||||
/**
|
||||
* 根据知识库id查询知识库内容
|
||||
*/
|
||||
export async function queryKnowledgeDataList(knowIdsRef: Ref<string[]>, dataListRef: Ref<any[]>) {
|
||||
const knowIds = unref(knowIdsRef);
|
||||
const dataList = unref(dataListRef);
|
||||
if (!knowIds || knowIds.length == 0) {
|
||||
dataListRef.value = [];
|
||||
return;
|
||||
}
|
||||
if (!dataList || dataList.length == 0) {
|
||||
dataListRef.value = await getKnowledgeDataList(knowIds);
|
||||
return
|
||||
}
|
||||
if (knowIds.length !== dataList.length) {
|
||||
dataListRef.value = await getKnowledgeDataList(knowIds);
|
||||
return
|
||||
}
|
||||
const dataIds = dataList.map((item: any) => item.id);
|
||||
const diffIds = knowIds.filter((id: string) => !dataIds.includes(id));
|
||||
if (diffIds.length > 0) {
|
||||
dataListRef.value = await getKnowledgeDataList(knowIds);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据知识库id查询知识库内容
|
||||
*/
|
||||
export async function getKnowledgeDataList(knowIds: string[]) {
|
||||
if (!knowIds || knowIds.length == 0) {
|
||||
return []
|
||||
}
|
||||
const res = await queryKnowledgeBathById({ids: knowIds.join(",")});
|
||||
if (res.success) {
|
||||
return res.result;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type {NodeConfig} from "../../types";
|
||||
import {NodeTypes} from "../../types";
|
||||
import LogicFlow from "@logicflow/core";
|
||||
|
||||
import {BaseNodeModel, BaseNodeView} from "../base-node";
|
||||
import {nodeDefWidth, nodeDefHeight} from "../base-node/const";
|
||||
import KnowledgeIcon from './KnowledgeIcon.vue'
|
||||
import KnowledgeSetting from './KnowledgeSetting.vue'
|
||||
import KnowledgeNodeVue from './KnowledgeNode.vue'
|
||||
|
||||
const config: NodeConfig = {
|
||||
type: NodeTypes.KNOWLEDGE,
|
||||
label: "知识库",
|
||||
docs: [
|
||||
'https://help.jeecg.com/aigc/flowNodes/nodeKnow',
|
||||
],
|
||||
components: {
|
||||
icon: KnowledgeIcon,
|
||||
setting: KnowledgeSetting,
|
||||
},
|
||||
lfNode: {
|
||||
type: NodeTypes.KNOWLEDGE,
|
||||
view: BaseNodeView,
|
||||
model: BaseNodeModel,
|
||||
component: KnowledgeNodeVue,
|
||||
},
|
||||
params: {
|
||||
width: nodeDefWidth,
|
||||
height: nodeDefHeight,
|
||||
},
|
||||
methods: {
|
||||
createNode,
|
||||
checkNode,
|
||||
},
|
||||
}
|
||||
|
||||
function createNode(data: Recordable) {
|
||||
return {
|
||||
id: data.id,
|
||||
remarks: '',
|
||||
type: config.type,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
properties: {
|
||||
text: data.text ?? config.label,
|
||||
options: {
|
||||
// 知识库模型ID
|
||||
knowIds: [],
|
||||
// 结果条数
|
||||
topNumber: 5,
|
||||
// 相似度
|
||||
similarity: 0.7
|
||||
},
|
||||
inputParams: [],
|
||||
outputParams: [
|
||||
{field: 'documents', name: '文档列表', type: 'object[]'},
|
||||
{field: 'data', name: '文档内容', type: 'string'},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkNode(_lf: LogicFlow, node: any, {
|
||||
checkInputParams,
|
||||
}) {
|
||||
const {properties} = node;
|
||||
const {options} = properties;
|
||||
const problems: string[] = [
|
||||
...checkInputParams({text: '查询变量', required: true, requiredName: false}),
|
||||
];
|
||||
if (!Array.isArray(options.knowIds) || options.knowIds.length === 0) {
|
||||
problems.push('必须选择至少一个知识库');
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<NodeIcon icon="lucide:brain" color="#12c499"/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import NodeIcon from "../base-node/NodeIcon.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,293 @@
|
||||
<!-- 模型选择 -->
|
||||
<template>
|
||||
<a-space>
|
||||
<a-select
|
||||
:value="model.modeId"
|
||||
:options="LLMOptions"
|
||||
style="width: 520px"
|
||||
@change="onModelChange"
|
||||
/>
|
||||
<a-popover trigger="click" placement="bottomRight">
|
||||
<template #content>
|
||||
<div class="model-params-popover">
|
||||
<!-- 预设 -->
|
||||
<div class="setting-item">
|
||||
<div style="width: 100%; text-align: right;">
|
||||
<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>
|
||||
<!-- 模型温度 -->
|
||||
<div class="setting-item">
|
||||
<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"/>
|
||||
<a-input-number v-bind="temperatureProps"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- 词汇属性 -->
|
||||
<div class="setting-item">
|
||||
<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"/>
|
||||
<a-input-number v-bind="topPProps"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- 话题属性 -->
|
||||
<div class="setting-item">
|
||||
<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"/>
|
||||
<a-input-number v-bind="presencePenaltyProps"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- 重复属性 -->
|
||||
<div class="setting-item">
|
||||
<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"/>
|
||||
<a-input-number v-bind="frequencyPenaltyProps"/>
|
||||
</a-space>
|
||||
</div>
|
||||
<!-- 最大回复 -->
|
||||
<div class="setting-item">
|
||||
<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"/>
|
||||
<a-input-number v-bind="maxTokensProps"/>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-button preIcon="ant-design:setting" style="width: 40px;"/>
|
||||
</a-popover>
|
||||
</a-space>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref, computed} from 'vue';
|
||||
import {defHttp} from "@/utils/http/axios";
|
||||
import {cloneDeep, omit} from 'lodash-es';
|
||||
|
||||
const props = defineProps({
|
||||
model: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:model'])
|
||||
|
||||
// 参数:温度
|
||||
const [, temperatureEnable, temperatureProps] = createParamProps<number>('temperature', {
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
}, 0.7);
|
||||
// 参数:词汇属性
|
||||
const [, topPEnable, topPProps] = createParamProps<number>('topP', {
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
}, 0.7);
|
||||
// 参数:话题属性
|
||||
const [, presencePenaltyEnable, presencePenaltyProps] = createParamProps<number>('presencePenalty', {
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
}, 0);
|
||||
// 参数:重复属性
|
||||
const [, frequencyPenaltyEnable, frequencyPenaltyProps] = createParamProps<number>('frequencyPenalty', {
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
}, 0);
|
||||
// 参数:最大回复
|
||||
const [, maxTokensEnable, maxTokensProps] = createParamProps<number>('maxTokens', {
|
||||
min: 1,
|
||||
max: 16000,
|
||||
step: 1,
|
||||
}, 520);
|
||||
|
||||
// 预设参数
|
||||
const presets = [
|
||||
{
|
||||
name: '创意',
|
||||
icon: 'fxemoji:star',
|
||||
params: {
|
||||
temperature: 0.8,
|
||||
topP: 0.9,
|
||||
presencePenalty: 0.1,
|
||||
frequencyPenalty: 0.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '平衡',
|
||||
icon: 'noto:balance-scale',
|
||||
params: {
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
presencePenalty: 0.2,
|
||||
frequencyPenalty: 0.3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '精确',
|
||||
icon: 'twemoji:direct-hit',
|
||||
params: {
|
||||
temperature: 0.2,
|
||||
topP: 0.7,
|
||||
presencePenalty: 0.5,
|
||||
frequencyPenalty: 0.5,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// 加载预设
|
||||
function onLoadPreset(idx: number) {
|
||||
const preset = presets[idx];
|
||||
if (!preset) {
|
||||
return
|
||||
}
|
||||
const model = cloneDeep(props.model)
|
||||
model.params = {
|
||||
...omit(model.params, ...Object.keys(preset.params)),
|
||||
...preset.params,
|
||||
};
|
||||
emitChange(model)
|
||||
}
|
||||
|
||||
// 参数介绍
|
||||
const tips = {
|
||||
temperature: '值越大,回复内容越赋有多样性创造性、随机性;设为0根据事实回答,希望得到精准答案应该降低该参数;日常聊天建议0.5-0.8。',
|
||||
topP: '值越小,Ai生成的内容越单调也越容易理解;值越大,Ai回复的词汇围越大,越多样化。',
|
||||
presencePenalty: '值越大,越能够让Ai更好地控制新话题的引入,建议微调或不变。',
|
||||
frequencyPenalty: '值越大,越能够让Ai更好地避免重复之前说过的话,建议微调或不变。',
|
||||
maxTokens: '设置Ai最大回复内容大小,会影响返回结果的长度。普通聊天建议500-800;短文生成建议800-2000;代码生成建议2000-3600;长文生成建议4000左右(或选择长回复模型)',
|
||||
}
|
||||
|
||||
// LLM模型选项
|
||||
const LLMOptions = ref<Recordable[]>([])
|
||||
|
||||
function loadLLMOptions() {
|
||||
const apiUrl = `/sys/dict/getDictItems/airag_model%20where%20model_type%20=%20'LLM',name,id`;
|
||||
defHttp.get({url: apiUrl}).then((res) => {
|
||||
LLMOptions.value = res
|
||||
LLMOptions.value.unshift({label: '请选择模型', value: ''})
|
||||
});
|
||||
}
|
||||
|
||||
loadLLMOptions();
|
||||
|
||||
function onModelChange(value: string, item: Recordable) {
|
||||
const model = cloneDeep(props.model)
|
||||
model.modeId = value
|
||||
model.params.model = value ? item.label : ''
|
||||
emitChange(model)
|
||||
}
|
||||
|
||||
function emitChange(model: Recordable) {
|
||||
emit('update:model', model)
|
||||
}
|
||||
|
||||
// 创建参数配置
|
||||
function createParamProps<T>(key: string, cfg: Recordable, defVal: T) {
|
||||
type ValType = Nullable<T>;
|
||||
// 参数的值
|
||||
const paramRef = createParamRef<ValType>(key)
|
||||
// 是否启用 defVal
|
||||
const enabled = computed<boolean>({
|
||||
get: () => paramRef.value != null,
|
||||
set: (val: boolean) => paramRef.value = val ? defVal : null,
|
||||
});
|
||||
return [
|
||||
paramRef,
|
||||
enabled,
|
||||
// 参数的配置
|
||||
computed<Recordable>(() => {
|
||||
return {
|
||||
...cfg,
|
||||
value: paramRef.value,
|
||||
size: 'small',
|
||||
disabled: !enabled.value,
|
||||
'onUpdate:value': (val: ValType) => paramRef.value = val,
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// 创建参数值ref
|
||||
function createParamRef<T>(key: string) {
|
||||
return computed({
|
||||
get: () => props.model.params[key],
|
||||
set: (val: T) => {
|
||||
const model = cloneDeep(props.model)
|
||||
model.params[key] = val
|
||||
emitChange(model)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.model-params-popover {
|
||||
width: 300px;
|
||||
|
||||
.setting-item .label {
|
||||
> span {
|
||||
vertical-align: middle;
|
||||
|
||||
&.app-iconify {
|
||||
cursor: help;
|
||||
color: #888888;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-space {
|
||||
.ant-slider {
|
||||
width: 164px;
|
||||
}
|
||||
|
||||
.ant-input-number {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<BaseNodeContainer v-bind="$props">
|
||||
<NodeKVS :kvs="nodeKVS"/>
|
||||
</BaseNodeContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {KVItemType} from "../base-node/const";
|
||||
import {computed} from "vue";
|
||||
import {BaseNodeContainer, NodeKVS} from "../base-node";
|
||||
import {useNode} from "../../hooks/useNode";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
},
|
||||
graph: {
|
||||
type: Object as PropType<Recordable>,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const {$properties, getInputParamKVItem, getOutputParamKVItem} = useNode(props)
|
||||
|
||||
const nodeKVS = computed<KVItemType[]>(() => {
|
||||
return [
|
||||
getInputParamKVItem({
|
||||
label: '输入变量',
|
||||
emptyAction: 'hidden',
|
||||
}),
|
||||
{
|
||||
label: '模型',
|
||||
value: $properties.value?.options?.model?.params?.model,
|
||||
emptyAction: 'tip',
|
||||
emptyTip: '尚未选择',
|
||||
},
|
||||
{
|
||||
label: '系统提示',
|
||||
value: $properties.value?.options?.messages?.[0]?.content,
|
||||
},
|
||||
{
|
||||
label: '用户提示',
|
||||
value: $properties.value?.options?.messages?.[1]?.content,
|
||||
},
|
||||
|
||||
getOutputParamKVItem({
|
||||
label: '输出变量',
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user