init
This commit is contained in:
132
src/views/knowledge/aiFlow/ApiPanel.vue
Normal file
132
src/views/knowledge/aiFlow/ApiPanel.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="(val: boolean) => emit('update:modelValue', val)"
|
||||
title="API 信息"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-md api-info">
|
||||
<div class="mb-4">
|
||||
<div class="p-4 bg-gray-50 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-lg font-semibold text-gray-800">接口地址</span>
|
||||
</div>
|
||||
<div class="pb-2 text-base text-gray-600 border-b border-gray-300">
|
||||
{{ apiUrl }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-lg font-semibold text-gray-800">请求方法</span>
|
||||
</div>
|
||||
<div class="text-base text-gray-600">POST</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-lg font-semibold text-gray-800">请求头</span>
|
||||
</div>
|
||||
<div class="text-base text-gray-600" v-html="headers"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-lg font-semibold text-gray-800">请求体</span>
|
||||
</div>
|
||||
<div class="text-base text-gray-600">
|
||||
{{ requestBody }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="font-medium">CURL 命令</span>
|
||||
<el-button type="primary" link @click="copyText(curlCommand)">复制</el-button>
|
||||
</div>
|
||||
<div class="w-full mockup-code">
|
||||
<pre data-prefix=""> <code >{{ curlCommand }}</code> </pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Session } from '/@/utils/storage';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
flowId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const isMicro = import.meta.env.VITE_IS_MICRO === 'true' ? true : false;
|
||||
const apiUrl = computed(() => `${window.location.origin}/api/${isMicro ? 'knowledge' : 'admin'}/aiFlow/execute`);
|
||||
|
||||
const headers = computed(
|
||||
() =>
|
||||
`Content-Type: application/json<br>
|
||||
Authorization: Bearer ${Session.getToken()}`
|
||||
);
|
||||
|
||||
const requestBody = computed(() =>
|
||||
JSON.stringify(
|
||||
{
|
||||
id: props.flowId,
|
||||
params: {},
|
||||
envs: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const curlCommand = computed(
|
||||
(): string =>
|
||||
`curl -X POST '${apiUrl.value}' \\
|
||||
--header 'Authorization: Bearer ${Session.getToken()}' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '{
|
||||
"id": "${props.flowId}",
|
||||
"params": {},
|
||||
"envs": {}
|
||||
}'`
|
||||
);
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ElMessage.success('复制成功');
|
||||
} catch (err) {
|
||||
ElMessage.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.api-info {
|
||||
code {
|
||||
font-family: Monaco, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
173
src/views/knowledge/aiFlow/CanvasContextMenu.vue
Normal file
173
src/views/knowledge/aiFlow/CanvasContextMenu.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div v-if="visible"
|
||||
class="context-menu"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
left: position.x + 'px',
|
||||
top: position.y + 'px',
|
||||
zIndex: 1000
|
||||
}">
|
||||
<!-- <div class="menu-item"
|
||||
@click="handleCommand('addNode')">
|
||||
<el-icon class="menu-icon">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
新增节点
|
||||
</div> -->
|
||||
<div class="menu-item"
|
||||
@click="handleCommand('importDSL')">
|
||||
<el-icon class="menu-icon">
|
||||
<Upload />
|
||||
</el-icon>
|
||||
导入DSL
|
||||
</div>
|
||||
<div class="menu-item"
|
||||
@click="handleCommand('exportDSL')">
|
||||
<el-icon class="menu-icon">
|
||||
<Download />
|
||||
</el-icon>
|
||||
导出DSL
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, defineComponent, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { Plus, Upload, Download } from '@element-plus/icons-vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CanvasContextMenu',
|
||||
components: {
|
||||
Plus,
|
||||
Upload,
|
||||
Download
|
||||
},
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
position: {
|
||||
type: Object,
|
||||
default: () => ({ x: 0, y: 0 })
|
||||
}
|
||||
},
|
||||
emits: ['update:visible', 'add-node', 'import-dsl', 'export-dsl'],
|
||||
setup (props, { emit }) {
|
||||
// 处理菜单命令
|
||||
const handleCommand = (command) => {
|
||||
switch (command) {
|
||||
case 'addNode':
|
||||
emit('add')
|
||||
break
|
||||
case 'importDSL':
|
||||
emit('import')
|
||||
break
|
||||
case 'exportDSL':
|
||||
emit('export')
|
||||
break
|
||||
}
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单的处理函数
|
||||
const handleClickOutside = (e) => {
|
||||
const contextMenu = document.querySelector('.context-menu')
|
||||
if (contextMenu && !contextMenu.contains(e.target)) {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时添加点击事件监听
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
// 组件销毁前移除事件监听
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
return {
|
||||
handleCommand,
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
opacity: 0.9;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
animation: menuFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: rgb(71 84 103 / 1);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f8fafc;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.node-types {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
|
||||
.el-radio {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menuFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-dialog) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.el-dialog__header {
|
||||
margin: 0;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
359
src/views/knowledge/aiFlow/ChatPanel.vue
Normal file
359
src/views/knowledge/aiFlow/ChatPanel.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="'流程运行' + id" :size="500" @close="$emit('close')" direction="rtl" class="chat-drawer">
|
||||
<div class="chat-container">
|
||||
<!-- 参数填写区域 -->
|
||||
<div class="message system-message" v-if="hasStartParams">
|
||||
<div class="message-content">
|
||||
<div class="param-list">
|
||||
<template v-for="(param, index) in startParams">
|
||||
<div :key="index" v-if="param.type != 'message'" class="param-item">
|
||||
<div class="param-label" :class="{ required: param.required }">
|
||||
{{ param.name }}
|
||||
</div>
|
||||
<div class="param-content">
|
||||
<input
|
||||
v-if="param.inputType === 'input'"
|
||||
v-model="param.value"
|
||||
class="param-input"
|
||||
:class="{ error: showError && param.required && !param.value }"
|
||||
:placeholder="'请输入' + param.name"
|
||||
/>
|
||||
<input
|
||||
v-else-if="param.inputType === 'number'"
|
||||
type="number"
|
||||
v-model.number="param.value"
|
||||
class="param-input"
|
||||
:class="{ error: showError && param.required && !param.value }"
|
||||
:placeholder="'请输入' + param.name"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="param.inputType === 'textarea'"
|
||||
v-model="param.value"
|
||||
class="param-textarea"
|
||||
:class="{ error: showError && param.required && !param.value }"
|
||||
:placeholder="'请输入' + param.name"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<select
|
||||
v-else-if="param.inputType === 'select'"
|
||||
v-model="param.value"
|
||||
class="param-select"
|
||||
:class="{ error: showError && param.required && !param.value }"
|
||||
>
|
||||
<option value="">请选择{{ param.name }}</option>
|
||||
<option v-for="option in param.options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 聊天消息区域 -->
|
||||
<ChatMessage :messages="messageList" ref="chatMessages" />
|
||||
|
||||
<!-- 底部输入区域 -->
|
||||
<div class="chat-input">
|
||||
<div class="input-area">
|
||||
<textarea
|
||||
v-model="message"
|
||||
class="chat-textarea"
|
||||
:rows="1"
|
||||
:disabled="parent.isRunning"
|
||||
placeholder="输入消息提问,Enter 发送,Shift + Enter 换行"
|
||||
@keyup.enter="handleSend"
|
||||
></textarea>
|
||||
<el-button
|
||||
class="btn-send"
|
||||
type="primary"
|
||||
@click.stop="handleSend"
|
||||
:disabled="parent.isRunning"
|
||||
:title="parent.isRunning ? '等待回复...' : '发送'"
|
||||
>
|
||||
<el-icon>
|
||||
<Position />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Close, Loading, Check, CircleClose, ArrowRight, Position } from '@element-plus/icons-vue';
|
||||
import NodeList from './components/NodeList.vue';
|
||||
import ChatMessage from './components/ChatMessage.vue';
|
||||
|
||||
export default {
|
||||
name: 'ChatPanel',
|
||||
inject: ['parent'],
|
||||
components: {
|
||||
ChatMessage,
|
||||
Close,
|
||||
Loading,
|
||||
Check,
|
||||
CircleClose,
|
||||
ArrowRight,
|
||||
Position,
|
||||
NodeList,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
executionNodes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
finalResult: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
executionTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
totalTokens: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
startParams: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showError: false,
|
||||
message: '',
|
||||
chatHistory: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
visible: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:modelValue', value);
|
||||
},
|
||||
},
|
||||
hasStartParams() {
|
||||
// 过滤掉type为message的参数,只返回其他类型参数的长度
|
||||
return this.startParams && this.startParams.filter((param) => param.type !== 'message').length > 0;
|
||||
},
|
||||
messageList() {
|
||||
let message = [];
|
||||
this.chatHistory.forEach((item) => {
|
||||
message.push(item);
|
||||
});
|
||||
if (this.executionNodes.length > 0 && this.parent.isRunning) {
|
||||
message.push({
|
||||
role: 'assistant',
|
||||
nodes: this.executionNodes,
|
||||
});
|
||||
}
|
||||
return message;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleSend() {
|
||||
if (this.parent.isRunning || !this.message.trim()) return;
|
||||
|
||||
let messageObj = this.startParams.find((param) => param.type == 'message');
|
||||
messageObj.value = this.message;
|
||||
|
||||
const hasError = this.startParams.some((param) => param.required && !param.value);
|
||||
this.showError = hasError;
|
||||
|
||||
if (hasError) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chatHistory.push({
|
||||
role: 'user',
|
||||
content: this.message,
|
||||
});
|
||||
|
||||
this.$emit('run', this.startParams);
|
||||
|
||||
this.message = '';
|
||||
this.showError = false;
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
const chatMessages = this.$refs.chatMessages.$el;
|
||||
if (chatMessages) {
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
formatTotalTime(time) {
|
||||
if (!time) return '0ms';
|
||||
return `${Number(time).toFixed(3)}ms`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
executionNodes: {
|
||||
handler(node) {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
finalResult: {
|
||||
handler(val) {
|
||||
this.chatHistory.push({
|
||||
role: 'assistant',
|
||||
nodes: this.executionNodes,
|
||||
result: val.result,
|
||||
});
|
||||
this.parent.isRunning = false;
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
'parent.isRunning'(newVal) {
|
||||
if (!newVal) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './styles/flow.scss';
|
||||
|
||||
.chat-drawer {
|
||||
:deep(.el-drawer__body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 80px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.param-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.execution-result {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
line-height: 25px;
|
||||
font-size: 12px;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
.chat-input {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-messages {
|
||||
height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
padding: 16px;
|
||||
|
||||
.param-list {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.param-label {
|
||||
width: 100px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.param-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.param-input,
|
||||
.param-select,
|
||||
.param-textarea {
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
background-color: #fff;
|
||||
transition: border-color 0.2s;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
border-color: #c0c4cc;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.param-input,
|
||||
.param-select {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.param-textarea {
|
||||
min-height: 80px;
|
||||
padding: 8px 12px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.error-tip {
|
||||
font-size: 12px;
|
||||
color: #f56c6c;
|
||||
line-height: 1.4;
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
451
src/views/knowledge/aiFlow/CheckListPanel.vue
Normal file
451
src/views/knowledge/aiFlow/CheckListPanel.vue
Normal file
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<div class="check-list-panel">
|
||||
<div class="preview-content">
|
||||
<div class="preview-header">
|
||||
<div class="header-title">
|
||||
<span>检查清单({{ validation.errors.length }})</span>
|
||||
</div>
|
||||
<el-button
|
||||
class="close-btn"
|
||||
type="primary"
|
||||
link
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="preview-body">
|
||||
<div class="check-list">
|
||||
<template v-if="validation.errors.length > 0">
|
||||
<div v-for="(error, index) in validation.errors"
|
||||
:key="index"
|
||||
class="check-item">
|
||||
<div class="item-icon warning">
|
||||
<el-icon><Warning /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-type">{{ getErrorType(error) }}</div>
|
||||
<div class="item-message">{{ getErrorMessage(error) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else
|
||||
class="check-item success">
|
||||
<div class="item-icon success">
|
||||
<el-icon><Select /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-type success">检测通过</div>
|
||||
<div class="item-message">工作流程检测已通过,可以发布</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Warning, Close, Select } from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'CheckListPanel',
|
||||
inject: ['parent'],
|
||||
components: {
|
||||
Warning,
|
||||
Close,
|
||||
Select
|
||||
},
|
||||
props: {
|
||||
validation: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:validation', 'close'],
|
||||
data () {
|
||||
return {
|
||||
showCheckList: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'parent.nodes': {
|
||||
handler: 'checkChanges',
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
'parent.connections': {
|
||||
handler: 'checkChanges',
|
||||
deep: true,
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkChanges () {
|
||||
const newValidation = this.validateWorkflow()
|
||||
this.$emit('update:validation', newValidation)
|
||||
},
|
||||
validateWorkflow () {
|
||||
const validation = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
try {
|
||||
if (!this.parent.nodes || this.parent.nodes.length === 0) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: '工作流中没有节点'
|
||||
});
|
||||
validation.isValid = false;
|
||||
return validation;
|
||||
}
|
||||
|
||||
const startNodes = this.parent.nodes.filter(node => node.type === 'start');
|
||||
const endNodes = this.parent.nodes.filter(node => node.type === 'end');
|
||||
|
||||
if (startNodes.length === 0) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: '工作流缺少开始节点'
|
||||
});
|
||||
validation.isValid = false;
|
||||
} else if (startNodes.length > 1) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: '工作流只能有一个开始节点'
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
|
||||
if (endNodes.length === 0) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: '工作流缺少结束节点'
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
|
||||
const nodeConnections = new Map();
|
||||
this.parent.nodes.forEach(node => {
|
||||
nodeConnections.set(node.id, {
|
||||
inbound: [],
|
||||
outbound: []
|
||||
});
|
||||
});
|
||||
|
||||
this.parent.connections.forEach(conn => {
|
||||
const sourceConn = nodeConnections.get(conn.sourceId);
|
||||
const targetConn = nodeConnections.get(conn.targetId);
|
||||
|
||||
if (sourceConn) {
|
||||
sourceConn.outbound.push(conn.targetId);
|
||||
}
|
||||
if (targetConn) {
|
||||
targetConn.inbound.push(conn.sourceId);
|
||||
}
|
||||
});
|
||||
|
||||
this.parent.nodes.forEach(node => {
|
||||
const nodeConn = nodeConnections.get(node.id);
|
||||
|
||||
if (node.type === 'start' && nodeConn.inbound.length > 0) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: '开始节点不能有入边连接',
|
||||
nodeId: node.id
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
|
||||
if (node.type === 'end' && nodeConn.outbound.length > 0) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: '结束节点不能有出边连接',
|
||||
nodeId: node.id
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
|
||||
if (nodeConn.inbound.length === 0 && nodeConn.outbound.length === 0 &&
|
||||
node.type !== 'start' && node.type !== 'end') {
|
||||
validation.warnings.push({
|
||||
type: 'warning',
|
||||
message: '存在孤立节点',
|
||||
nodeId: node.id
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.parent.nodes.forEach(node => {
|
||||
// switch (node.type) {
|
||||
// case 'http':
|
||||
// this.validateHttpNode(node, validation);
|
||||
// break;
|
||||
// case 'code':
|
||||
// this.validateCodeNode(node, validation);
|
||||
// break;
|
||||
// }
|
||||
});
|
||||
|
||||
if (this.hasCircularDependency()) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: '工作流中存在循环依赖'
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
|
||||
return validation;
|
||||
} catch (error) {
|
||||
console.error('验证工作流时出错:', error);
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: '验证工作流时出错: ' + error.message
|
||||
});
|
||||
validation.isValid = false;
|
||||
return validation;
|
||||
}
|
||||
},
|
||||
validateHttpNode (node, validation) {
|
||||
if (!node.url) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: 'HTTP节点缺少URL',
|
||||
nodeId: node.id
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
|
||||
if (!node.method) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: 'HTTP节点缺少请求方法',
|
||||
nodeId: node.id
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
|
||||
if (node.headers) {
|
||||
node.headers.forEach((header, index) => {
|
||||
if (!header.name) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: `HTTP节点的第${index + 1}个请求头缺少名称`,
|
||||
nodeId: node.id
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (node.method !== 'GET' && node.bodyParams) {
|
||||
node.bodyParams.forEach((param, index) => {
|
||||
if (!param.name) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: `HTTP节点的第${index + 1}个请求体参数缺少名称`,
|
||||
nodeId: node.id
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
validateCodeNode (node, validation) {
|
||||
if (!node.code) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: '代码节点缺少执行代码',
|
||||
nodeId: node.id
|
||||
});
|
||||
validation.isValid = false;
|
||||
} else {
|
||||
try {
|
||||
new Function(node.code);
|
||||
} catch (error) {
|
||||
validation.errors.push({
|
||||
type: 'error',
|
||||
message: `代码语法错误: ${error.message}`,
|
||||
nodeId: node.id
|
||||
});
|
||||
validation.isValid = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
hasCircularDependency () {
|
||||
const visited = new Set();
|
||||
const recursionStack = new Set();
|
||||
|
||||
const dfs = (nodeId) => {
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
|
||||
const connections = this.parent.connections.filter(conn => conn.sourceId === nodeId);
|
||||
for (const conn of connections) {
|
||||
const targetId = conn.targetId;
|
||||
|
||||
if (!visited.has(targetId)) {
|
||||
if (dfs(targetId)) {
|
||||
return true;
|
||||
}
|
||||
} else if (recursionStack.has(targetId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const node of this.parent.nodes) {
|
||||
if (!visited.has(node.id)) {
|
||||
if (dfs(node.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
getErrorType (error) {
|
||||
if (error.message.includes('缺少')) return '节点缺失'
|
||||
if (error.message.includes('未连接')) return '连接错误'
|
||||
if (error.message.includes('循环')) return '循环依赖'
|
||||
return '错误'
|
||||
},
|
||||
getErrorMessage (error) {
|
||||
let nodeName = ''
|
||||
if (error.nodeId) {
|
||||
const node = this.parent.nodes.find(n => n.id === error.nodeId)
|
||||
if (node) {
|
||||
nodeName = node.name || `${node.type}节点`
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeName) {
|
||||
return `${nodeName}: ${error.message}`
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.check-list-panel {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 1;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
span:first-child {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
padding: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.check-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.check-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
|
||||
&.success {
|
||||
background: #f0fdf4;
|
||||
border-color: #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #ef4444;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.warning {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-type {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #f59e0b;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&.success {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
.item-message {
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
143
src/views/knowledge/aiFlow/EnvSettingsPanel.vue
Normal file
143
src/views/knowledge/aiFlow/EnvSettingsPanel.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<el-dialog title="环境变量设置" v-model="visible" width="650px" class="env-settings-dialog">
|
||||
<div class="space-y-4">
|
||||
<!-- 顶部操作区 -->
|
||||
<div class="flex justify-between items-center pb-3 border-b border-gray-200">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span>设置流程中全局可使用的变量</span>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
class="flex gap-1 items-center text-white bg-blue-500 rounded-md transition-colors hover:bg-blue-600"
|
||||
type="primary"
|
||||
@click="addEnvVariable"
|
||||
>
|
||||
<i class="el-icon-plus"></i>
|
||||
<span>添加</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 变量列表 -->
|
||||
<div class="space-y-2">
|
||||
<transition-group name="env-list">
|
||||
<div
|
||||
v-for="(item, index) in parent.env"
|
||||
:key="index"
|
||||
class="flex gap-3 items-center p-2 rounded-lg transition-colors group hover:bg-gray-50"
|
||||
>
|
||||
<div class="w-2/5">
|
||||
<el-input v-model="item.name" placeholder="请输入变量名" class="w-full" />
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<el-input v-model="item.value" placeholder="请输入变量值" class="w-full" />
|
||||
</div>
|
||||
<div class="w-auto">
|
||||
<el-button icon="delete" class="text-gray-400 transition-colors hover:text-red-500" @click="deleteEnvVariable(index)" />
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="!parent.env.length" class="py-8">
|
||||
<el-empty description="暂无环境变量" :image-size="100" class="flex flex-col items-center">
|
||||
<el-button type="primary" class="mt-4 text-white bg-blue-500 rounded-md transition-colors hover:bg-blue-600" @click="addEnvVariable">
|
||||
添加第一个环境变量
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区 -->
|
||||
<template #footer>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="visible = false">确认</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EnvSettingsPanel',
|
||||
inject: ['parent'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
visible: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:modelValue', value);
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
keyPattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// Ensure parent.env is initialized as an array
|
||||
if (!this.parent.env || !Array.isArray(this.parent.env)) {
|
||||
this.parent.env = [];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addEnvVariable() {
|
||||
// Ensure parent.env is an array before pushing
|
||||
if (!Array.isArray(this.parent.env)) {
|
||||
this.parent.env = [];
|
||||
}
|
||||
this.parent.env.push({
|
||||
name: '',
|
||||
value: '',
|
||||
});
|
||||
},
|
||||
|
||||
deleteEnvVariable(index) {
|
||||
if (Array.isArray(this.parent.env)) {
|
||||
this.parent.env.splice(index, 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.env-settings-dialog {
|
||||
/* 基础样式通过 Tailwind 实现 */
|
||||
|
||||
/* 动画效果 */
|
||||
.env-list-enter-active,
|
||||
.env-list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.env-list-enter-from,
|
||||
.env-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
:deep(.el-input) {
|
||||
.el-input__wrapper {
|
||||
@apply border border-gray-300 hover:border-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-button) {
|
||||
@apply transition-all duration-200;
|
||||
|
||||
&.is-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1151
src/views/knowledge/aiFlow/ExecutionPanel.vue
Normal file
1151
src/views/knowledge/aiFlow/ExecutionPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
196
src/views/knowledge/aiFlow/GreetingEditor.vue
Normal file
196
src/views/knowledge/aiFlow/GreetingEditor.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
|
||||
<!-- 开场白和开场问题编辑器 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="提示词"
|
||||
width="600px"
|
||||
:show-footer="false"
|
||||
>
|
||||
<div class="greeting-editor">
|
||||
<!-- 开场白编辑区 -->
|
||||
<div class="greeting-section">
|
||||
<div class="section-title">
|
||||
<div class="title-with-help">
|
||||
<span>聊天开场白</span>
|
||||
<el-tooltip content="开场白会在用户进入对话时首先展示,用于介绍AI助手的功能和特点。"
|
||||
placement="top">
|
||||
<el-icon class="help-icon">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="form.greeting"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
class="greeting-input"
|
||||
placeholder="在这里编写AI助手的开场白"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 开场问题编辑区 -->
|
||||
<div class="questions-section">
|
||||
<div class="section-title">
|
||||
<div class="title-with-help">
|
||||
<span>开场问题</span>
|
||||
<div class="question-count">{{ form.questions.length }}/10</div>
|
||||
<el-tooltip content="设置常见问题示例,帮助用户快速开始对话。最多可设置10个问题。"
|
||||
placement="top">
|
||||
<el-icon class="help-icon">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="add-question"
|
||||
@click="addQuestion"
|
||||
:disabled="form.questions.length >= 10"
|
||||
size="small"
|
||||
>
|
||||
<el-icon class="icon-plus">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="questions-list">
|
||||
<div v-for="(question, index) in form.questions"
|
||||
:key="index"
|
||||
class="question-item">
|
||||
<el-input
|
||||
v-model="question.text"
|
||||
class="question-input"
|
||||
:placeholder="'问题 ' + (index + 1)"
|
||||
/>
|
||||
<el-button
|
||||
@click="removeQuestion(index)"
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
circle
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { QuestionFilled, Plus, Delete } from '@element-plus/icons-vue'
|
||||
|
||||
// 注入parent
|
||||
const parent = inject('parent')
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 对话框显示状态
|
||||
const dialogVisible = ref(props.modelValue)
|
||||
|
||||
|
||||
const form = ref({
|
||||
questions: []
|
||||
})
|
||||
|
||||
// 监听modelValue的变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
form.value = parent.dsl;
|
||||
form.value.questions=form.value.questions || []
|
||||
dialogVisible.value = newVal
|
||||
})
|
||||
|
||||
// 监听dialogVisible的变化
|
||||
watch(() => dialogVisible.value, (newVal) => {
|
||||
emit('update:modelValue', newVal)
|
||||
})
|
||||
|
||||
|
||||
// 添加问题
|
||||
const addQuestion = () => {
|
||||
if (form.value.questions.length < 10) {
|
||||
form.value.questions.push({ text: '' })
|
||||
}
|
||||
}
|
||||
|
||||
// 删除问题
|
||||
const removeQuestion = (index) => {
|
||||
form.value.questions.splice(index, 1)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.greeting-editor {
|
||||
.greeting-section,
|
||||
.questions-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.title-with-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.help-icon {
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.question-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.greeting-input {
|
||||
:deep(.el-textarea__inner) {
|
||||
min-height: 60px;
|
||||
max-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.questions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.question-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.question-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
174
src/views/knowledge/aiFlow/JsonPreviewPanel.vue
Normal file
174
src/views/knowledge/aiFlow/JsonPreviewPanel.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
title="数据结构"
|
||||
v-model="dialogVisible"
|
||||
width="800px"
|
||||
class="json-preview-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
>
|
||||
<div class="preview-content">
|
||||
<!-- 顶部标签页 -->
|
||||
<el-tabs v-model="activeTab" class="preview-tabs">
|
||||
<el-tab-pane
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:label="tab.label"
|
||||
:name="tab.key"
|
||||
>
|
||||
<template #label>
|
||||
<span>{{ tab.label }}</span>
|
||||
<el-tag v-if="tab.key === 'nodes'" size="small" type="info" class="ml-2">
|
||||
{{ nodeCount }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 代码编辑器 -->
|
||||
<div class="preview-body">
|
||||
<code-editor
|
||||
v-model="currentTabData"
|
||||
:json="true"
|
||||
:readonly="false"
|
||||
theme="nord"
|
||||
height="400px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="copyData">
|
||||
<el-icon><Document /></el-icon>
|
||||
复制内容
|
||||
</el-button>
|
||||
<el-button @click="dialogVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Document } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import CodeEditor from "/@/views/knowledge/aiFlow/components/CodeEditor.vue";
|
||||
|
||||
export default {
|
||||
name: 'JsonPreviewPanel',
|
||||
components: {
|
||||
CodeEditor,
|
||||
Document
|
||||
},
|
||||
inject: ['parent'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'all',
|
||||
tabs: [
|
||||
{ key: 'all', label: '全部数据' },
|
||||
{ key: 'nodes', label: '节点数据' },
|
||||
{ key: 'connections', label: '连线数据' },
|
||||
{ key: 'execution', label: '执行顺序' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dialogVisible: {
|
||||
get() {
|
||||
return this.modelValue
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:modelValue', value)
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
nodes: this.parent.nodes,
|
||||
connections: this.parent.connections,
|
||||
executionOrder: this.parent.workflowExecutionOrder,
|
||||
}
|
||||
},
|
||||
nodeCount() {
|
||||
return this.data.nodes ? this.data.nodes.length : 0
|
||||
},
|
||||
currentTabData: {
|
||||
get() {
|
||||
let data = ''
|
||||
switch (this.activeTab) {
|
||||
case 'nodes':
|
||||
data = this.data.nodes
|
||||
break
|
||||
case 'connections':
|
||||
data = this.data.connections
|
||||
break
|
||||
case 'execution':
|
||||
data = this.data.executionOrder
|
||||
break
|
||||
default:
|
||||
data = this.data
|
||||
}
|
||||
return JSON.stringify(data, null, 2)
|
||||
},
|
||||
set() {
|
||||
// 只读模式,不需要实现set
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
copyData() {
|
||||
navigator.clipboard.writeText(this.currentTabData)
|
||||
.then(() => {
|
||||
ElMessage({
|
||||
message: '复制成功',
|
||||
type: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
ElMessage({
|
||||
message: '复制失败',
|
||||
type: 'error',
|
||||
duration: 2000
|
||||
})
|
||||
console.error('复制失败:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.json-preview-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
.preview-tabs {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 15px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
:deep(.el-tag) {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
329
src/views/knowledge/aiFlow/MiniMap.vue
Normal file
329
src/views/knowledge/aiFlow/MiniMap.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<!-- 缩略图组件 -->
|
||||
<template>
|
||||
<div class="mini-map" :style="{ width: width + 'px', height: height + 'px' }">
|
||||
<!-- 缩略图容器 -->
|
||||
<div class="mini-map-container" ref="container">
|
||||
<!-- 缩略图内容 -->
|
||||
<div class="mini-map-content"
|
||||
:style="{ transform: `translate(${contentPosition.x}px, ${contentPosition.y}px) scale(${scale})` }">
|
||||
<!-- 节点缩略图 -->
|
||||
<div v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="mini-node"
|
||||
:style="{
|
||||
left: `${node.x}px`,
|
||||
top: `${node.y}px`,
|
||||
backgroundColor: getNodeColor(node.type)
|
||||
}">
|
||||
</div>
|
||||
<!-- 连线缩略图 -->
|
||||
<svg class="mini-connections" :style="{ width: `${bounds.width}px`, height: `${bounds.height}px` }">
|
||||
<path v-for="(conn, index) in connections"
|
||||
:key="index"
|
||||
:d="getConnectionPath(conn)"
|
||||
class="mini-connection-path"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 视口指示器 -->
|
||||
<div class="viewport-indicator"
|
||||
:style="{
|
||||
transform: `translate(${viewportPosition.x}px, ${viewportPosition.y}px)`,
|
||||
width: `${viewportSize.width}px`,
|
||||
height: `${viewportSize.height}px`
|
||||
}"
|
||||
@mousedown.prevent="startDrag">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MiniMap',
|
||||
props: {
|
||||
// 缩略图宽度
|
||||
width: {
|
||||
type: Number,
|
||||
default: 200
|
||||
},
|
||||
// 缩略图高度
|
||||
height: {
|
||||
type: Number,
|
||||
default: 150
|
||||
},
|
||||
// 节点数据
|
||||
nodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 连接数据
|
||||
connections: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 画布缩放比例
|
||||
zoom: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
// 画布位置
|
||||
position: {
|
||||
type: Object,
|
||||
default: () => ({ x: 0, y: 0 })
|
||||
},
|
||||
// 添加容器尺寸属性
|
||||
containerSize: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scale: 0.1,
|
||||
isDragging: false,
|
||||
dragStart: { x: 0, y: 0 },
|
||||
bounds: {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
width: 3000,
|
||||
height: 3000
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 计算内容位置,使其居中显示
|
||||
contentPosition() {
|
||||
const offsetX = (this.width - this.bounds.width * this.scale) / 2
|
||||
const offsetY = (this.height - this.bounds.height * this.scale) / 2
|
||||
return {
|
||||
x: offsetX,
|
||||
y: offsetY
|
||||
}
|
||||
},
|
||||
// 修改视口位置计算
|
||||
viewportPosition() {
|
||||
// 确保位置不会超出边界
|
||||
const maxX = this.width - this.viewportSize.width
|
||||
const maxY = this.height - this.viewportSize.height
|
||||
|
||||
let x = (-this.position.x * this.scale) + this.contentPosition.x
|
||||
let y = (-this.position.y * this.scale) + this.contentPosition.y
|
||||
|
||||
// 限制在有效范围内
|
||||
x = Math.max(0, Math.min(x, maxX))
|
||||
y = Math.max(0, Math.min(y, maxY))
|
||||
|
||||
return { x, y }
|
||||
},
|
||||
// 修改视口尺寸计算
|
||||
viewportSize() {
|
||||
// 计算缩略图内容的实际显示范围
|
||||
const contentWidth = this.bounds.width * this.scale
|
||||
const contentHeight = this.bounds.height * this.scale
|
||||
|
||||
// 计算视口尺寸比例
|
||||
const viewportRatioX = this.width / (this.bounds.width / this.zoom)
|
||||
const viewportRatioY = this.height / (this.bounds.height / this.zoom)
|
||||
|
||||
// 确保视口尺寸不会小于最小值或大于缩略图尺寸
|
||||
return {
|
||||
width: Math.min(this.width, Math.max(50, contentWidth * viewportRatioX)),
|
||||
height: Math.min(this.height, Math.max(30, contentHeight * viewportRatioY))
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 监听节点变化,重新计算边界和缩放
|
||||
nodes: {
|
||||
handler() {
|
||||
this.$nextTick(this.updateBoundsAndScale)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 获取节点颜色
|
||||
getNodeColor(type) {
|
||||
const colors = {
|
||||
start: '#10B981',
|
||||
end: '#EF4444',
|
||||
http: '#3B82F6',
|
||||
switch: '#F59E0B',
|
||||
code: '#8B5CF6',
|
||||
db: '#6366F1',
|
||||
llm: '#EC4899',
|
||||
notice: '#14B8A6',
|
||||
question: '#F97316',
|
||||
default: '#6B7280'
|
||||
}
|
||||
return colors[type] || colors.default
|
||||
},
|
||||
// 获取连接路径
|
||||
getConnectionPath(conn) {
|
||||
const source = this.nodes.find(n => n.id === conn.sourceId)
|
||||
const target = this.nodes.find(n => n.id === conn.targetId)
|
||||
if (!source || !target) return ''
|
||||
|
||||
const x1 = source.x
|
||||
const y1 = source.y
|
||||
const x2 = target.x
|
||||
const y2 = target.y
|
||||
|
||||
// 计算控制点
|
||||
const dx = Math.abs(x2 - x1) * 0.5
|
||||
const cp1x = x1 + dx
|
||||
const cp1y = y1
|
||||
const cp2x = x2 - dx
|
||||
const cp2y = y2
|
||||
|
||||
return `M ${x1} ${y1} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`
|
||||
},
|
||||
// 开始拖动视口
|
||||
startDrag(event) {
|
||||
this.isDragging = true
|
||||
const rect = this.$refs.container.getBoundingClientRect()
|
||||
this.dragStart = {
|
||||
x: event.clientX - rect.left - this.viewportPosition.x,
|
||||
y: event.clientY - rect.top - this.viewportPosition.y
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', this.onDrag)
|
||||
window.addEventListener('mouseup', this.stopDrag)
|
||||
},
|
||||
// 拖动中
|
||||
onDrag(event) {
|
||||
if (!this.isDragging) return
|
||||
|
||||
const rect = this.$refs.container.getBoundingClientRect()
|
||||
let x = event.clientX - rect.left - this.dragStart.x
|
||||
let y = event.clientY - rect.top - this.dragStart.y
|
||||
|
||||
// 添加边界限制
|
||||
const maxX = this.width - this.viewportSize.width
|
||||
const maxY = this.height - this.viewportSize.height
|
||||
x = Math.max(0, Math.min(x, maxX))
|
||||
y = Math.max(0, Math.min(y, maxY))
|
||||
|
||||
// 计算相对于内容的位置
|
||||
const relativeX = (x - this.contentPosition.x) / this.scale
|
||||
const relativeY = (y - this.contentPosition.y) / this.scale
|
||||
|
||||
this.$emit('update:position', {
|
||||
x: -relativeX,
|
||||
y: -relativeY
|
||||
})
|
||||
},
|
||||
// 停止拖动
|
||||
stopDrag() {
|
||||
this.isDragging = false
|
||||
window.removeEventListener('mousemove', this.onDrag)
|
||||
window.removeEventListener('mouseup', this.stopDrag)
|
||||
},
|
||||
// 更新边界和缩放
|
||||
updateBoundsAndScale() {
|
||||
if (!this.nodes.length) return
|
||||
|
||||
// 计算节点边界
|
||||
const nodePositions = this.nodes.map(node => ({
|
||||
left: node.x - 100, // 考虑节点宽度
|
||||
right: node.x + 100,
|
||||
top: node.y - 50, // 考虑节点高度
|
||||
bottom: node.y + 50
|
||||
}))
|
||||
|
||||
// 计算整体边界
|
||||
const minX = Math.min(...nodePositions.map(p => p.left))
|
||||
const maxX = Math.max(...nodePositions.map(p => p.right))
|
||||
const minY = Math.min(...nodePositions.map(p => p.top))
|
||||
const maxY = Math.max(...nodePositions.map(p => p.bottom))
|
||||
|
||||
// 添加边距
|
||||
const PADDING = 100
|
||||
this.bounds = {
|
||||
minX: minX - PADDING,
|
||||
minY: minY - PADDING,
|
||||
width: maxX - minX + PADDING * 2,
|
||||
height: maxY - minY + PADDING * 2
|
||||
}
|
||||
|
||||
// 计算合适的缩放比例
|
||||
const scaleX = this.width / this.bounds.width
|
||||
const scaleY = this.height / this.bounds.height
|
||||
this.scale = Math.min(scaleX, scaleY, 0.2) // 限制最大缩放比例为0.2
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateBoundsAndScale()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mini-map {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 60px;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
|
||||
.mini-map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mini-map-content {
|
||||
position: absolute;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.mini-node {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.mini-connections {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.mini-connection-path {
|
||||
fill: none;
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.viewport-indicator {
|
||||
position: absolute;
|
||||
border: 1px solid #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.05); // 降低默认透明度,提高对比度
|
||||
pointer-events: all;
|
||||
cursor: move;
|
||||
border-radius: 4px; // 添加圆角
|
||||
transition: all 0.2s ease; // 添加过渡动画
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.15); // 提高hover时的透明度
|
||||
border-color: #2563eb; // hover时加深边框颜色
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); // hover时加深阴影
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(59, 130, 246, 0.2); // 点击时加深背景色
|
||||
border-color: #1d4ed8; // 点击时进一步加深边框
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
184
src/views/knowledge/aiFlow/NodeContextMenu.vue
Normal file
184
src/views/knowledge/aiFlow/NodeContextMenu.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div v-if="visible" class="context-menu" :style="menuStyle">
|
||||
<div v-if="!canAddRight && addPosition === 'right'" class="menu-disabled-item">
|
||||
<el-icon class="menu-icon"><WarningFilled /></el-icon>
|
||||
该节点已有子节点
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="menu-item" v-for="item in nodeTypes" :key="item.type" @click="handleAddNode(item.type)">
|
||||
<svg-icon :size="24" :class="['menu-icon', 'node-icon', 'node-icon--' + item.type]" :name="`local-${item.type}`" />
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { nodeTypes } from './nodes/nodeTypes.ts';
|
||||
import { WarningFilled } from '@element-plus/icons-vue';
|
||||
|
||||
export default {
|
||||
name: 'NodeContextMenu',
|
||||
components: {
|
||||
WarningFilled,
|
||||
},
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
position: {
|
||||
type: Object,
|
||||
default: () => ({ x: 0, y: 0 }),
|
||||
},
|
||||
node: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
addPosition: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
portIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
inject: ['parent'],
|
||||
computed: {
|
||||
menuStyle() {
|
||||
return {
|
||||
left: `${this.position.x}px`,
|
||||
top: `${this.position.y}px`,
|
||||
};
|
||||
},
|
||||
canAddNode() {
|
||||
return this.addPosition && this.node;
|
||||
},
|
||||
// 检查是否可以向右添加节点
|
||||
canAddRight() {
|
||||
if (this.addPosition !== 'right' || !this.node || !this.parent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取当前节点已有的连接数量
|
||||
const existingConnections = this.parent.connections?.filter((conn) => conn.sourceId === this.node.id) || [];
|
||||
|
||||
// 如果不是分支节点且已经有连接,则不能向右添加
|
||||
if (!['switch', 'question'].includes(this.node.type) && existingConnections.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
nodeTypes() {
|
||||
// 如果不能向右添加,返回空数组
|
||||
if (!this.canAddRight) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let types = nodeTypes.map((config) => ({
|
||||
type: config.type,
|
||||
name: config.name,
|
||||
}));
|
||||
|
||||
if (this.addPosition === 'replace') {
|
||||
types = types.filter((node) => node.type !== 'start' && node.type !== this.node?.type);
|
||||
} else {
|
||||
types = types.filter((node) => node.type !== 'start');
|
||||
}
|
||||
|
||||
return types;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleAddNode(type) {
|
||||
this.$emit('add', type);
|
||||
this.$emit('update:visible', false);
|
||||
},
|
||||
handleClickOutside(e) {
|
||||
if (!this.$el.contains(e.target)) {
|
||||
this.$emit('update:visible', false);
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (!val) {
|
||||
this.$emit('update:visible', false);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// 点击外部关闭菜单
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
opacity: 0.9;
|
||||
position: fixed;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
padding: 6px 0;
|
||||
min-width: 180px;
|
||||
z-index: 1000;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
animation: menuFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: rgb(71 84 103 / 1);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #f8fafc;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.menu-disabled-item {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
padding: 3px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background-color: #e5e7eb;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
@keyframes menuFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
273
src/views/knowledge/aiFlow/NodeItem.vue
Normal file
273
src/views/knowledge/aiFlow/NodeItem.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div
|
||||
:id="node.id"
|
||||
:ref="node.id"
|
||||
class="workflow-node"
|
||||
:class="[
|
||||
node.type,
|
||||
{
|
||||
'node-selected': isSelected,
|
||||
},
|
||||
]"
|
||||
:style="nodeStyle"
|
||||
@mouseenter="$emit('node-mouseenter', node)"
|
||||
@mouseleave="$emit('node-mouseleave', node)"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseover="handleMouseOver"
|
||||
@click="handleNodeClick"
|
||||
@contextmenu="handleContextMenu"
|
||||
>
|
||||
<!-- 右上角菜单按钮 -->
|
||||
<div class="more-btn" @click.stop="$emit('showContextMenu', $event, node)">
|
||||
<i class="more-icon">⋮</i>
|
||||
</div>
|
||||
<template v-if="node.canBeTarget">
|
||||
<!-- 左侧添加按钮 -->
|
||||
<div :id="`${node.id}-input`" class="node-port node-port-input add-btn left-btn" @click.stop="$emit('showMenu', $event, node, 'left')">+</div>
|
||||
</template>
|
||||
|
||||
<div class="node-content">
|
||||
<div class="node-header">
|
||||
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + node.type]" :name="`local-${node.type}`" />
|
||||
<div class="node-title">{{ node.title || node.name }}</div>
|
||||
</div>
|
||||
<!-- 节点内容 -->
|
||||
<component :is="nodeComponent" :node="node" />
|
||||
</div>
|
||||
<template v-if="node.canBeSource">
|
||||
<!-- 右侧添加按钮 -->
|
||||
<div
|
||||
v-for="(port, index) in outputPorts"
|
||||
:key="index"
|
||||
:id="`${node.id}-output-${index}`"
|
||||
class="node-port node-port-output add-btn right-btn"
|
||||
:style="getStyle(index, outputPorts.length)"
|
||||
:data-port-name="port.name"
|
||||
@click.stop="$emit('showMenu', $event, node, 'right', index)"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 使用 import.meta.glob 动态导入所有节点组件
|
||||
const modules = import.meta.glob('./nodes/*.vue', { eager: true });
|
||||
|
||||
export default {
|
||||
name: 'WorkflowNode',
|
||||
inject: ['parent'],
|
||||
props: {
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
nodeStyle() {
|
||||
return {
|
||||
left: `${this.node.x}px`,
|
||||
top: `${this.node.y}px`,
|
||||
};
|
||||
},
|
||||
nodeComponent() {
|
||||
// 从文件名中提取组件类型
|
||||
const nodeName = `${this.node.type.charAt(0).toUpperCase()}${this.node.type.slice(1)}Node`;
|
||||
// 查找对应的面板组件
|
||||
const node = Object.values(modules).find((module) => module.default.name === nodeName);
|
||||
return node?.default;
|
||||
},
|
||||
// 添加输出端口计算属性
|
||||
outputPorts() {
|
||||
// 根据节点类型返回不同数量的端口
|
||||
// 根据节点类型返回对应的端口配置
|
||||
const portConfigs = {
|
||||
question: () => {
|
||||
const length = this.node.questionParams?.categories?.length || 1;
|
||||
return Array.from({ length }, (_, i) => ({ name: this.node.questionParams?.categories?.[i]?.name || `事件${i + 1}` }));
|
||||
},
|
||||
switch: () => {
|
||||
const length = this.node.switchParams?.cases?.length || 1;
|
||||
return Array.from({ length }, (_, i) => ({ name: this.node.switchParams?.cases?.[i]?.name || `事件${i + 1}` }));
|
||||
},
|
||||
default: () => [{ name: '' }],
|
||||
};
|
||||
|
||||
// 使用节点类型获取对应的端口配置,如果没有则使用默认配置
|
||||
return (portConfigs[this.node.type] || portConfigs.default)();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDragging: false,
|
||||
moreMenuVisible: false,
|
||||
menuPosition: { x: 0, y: 0 },
|
||||
moreMenuNode: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleMouseDown(event) {
|
||||
this.isDragging = false;
|
||||
this.parent.selectedNode = this.node;
|
||||
},
|
||||
|
||||
handleMouseOver() {
|
||||
this.$nextTick(() => {
|
||||
this.isDragging = true;
|
||||
});
|
||||
},
|
||||
handleNodeClick(event) {
|
||||
// 只有当不是拖动时才触发选择事件
|
||||
if (!this.isDragging) {
|
||||
this.$emit('select', this.node, event);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取端口样式
|
||||
getStyle(index, len) {
|
||||
// 如果只有一个端口,居中显示
|
||||
if (len == 1) {
|
||||
return {
|
||||
top: `50%`,
|
||||
transform: 'translateY(-50%)',
|
||||
};
|
||||
}
|
||||
|
||||
// 计算多个端口的间距
|
||||
const spacing = 100 / (len - 1);
|
||||
return {
|
||||
top: `${spacing * index}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
};
|
||||
},
|
||||
|
||||
// 添加右键菜单处理方法
|
||||
handleContextMenu(event) {
|
||||
// 阻止默认右键菜单
|
||||
event.preventDefault();
|
||||
// 触发父组件的showMenu事件
|
||||
this.$emit('showContextMenu', event, this.node);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.workflow-node {
|
||||
position: absolute;
|
||||
width: 250px;
|
||||
min-height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #fff;
|
||||
}
|
||||
.node-content {
|
||||
width: 100%;
|
||||
}
|
||||
.node-header {
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.node-title {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
// display: none;
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
line-height: 100%;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: attr(data-port-name);
|
||||
position: absolute;
|
||||
right: -60px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.left-btn {
|
||||
left: -8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.right-btn {
|
||||
right: -8px;
|
||||
display: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.workflow-node:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
.left-btn,
|
||||
.right-btn {
|
||||
display: block;
|
||||
}
|
||||
.more-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.node-selected {
|
||||
border: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
/* 添加更多按钮的样式 */
|
||||
.more-btn {
|
||||
padding: 2px 5px;
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: -5px;
|
||||
padding: 3px 5px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
color: #666;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
313
src/views/knowledge/aiFlow/NodeMoreMenu.vue
Normal file
313
src/views/knowledge/aiFlow/NodeMoreMenu.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="visible"
|
||||
class="more-menu"
|
||||
:style="menuStyle">
|
||||
<!-- 拷贝 -->
|
||||
<div class="menu-item"
|
||||
@click="copyNode">
|
||||
<span>复制</span>
|
||||
</div>
|
||||
|
||||
<!-- 新增子节点 -->
|
||||
<div class="menu-item"
|
||||
@click="handleShowAddChild"
|
||||
v-if="canAddChild">
|
||||
<span>新增子节点</span>
|
||||
<i class="el-icon-arrow-right"></i>
|
||||
</div>
|
||||
|
||||
<!-- 更换类型 -->
|
||||
<div class="menu-item"
|
||||
@click="handleShowChangeType">
|
||||
<span>更换类型</span>
|
||||
<i class="el-icon-arrow-right"></i>
|
||||
</div>
|
||||
|
||||
<!-- 删除 -->
|
||||
<div class="menu-item"
|
||||
@click="deleteNode" v-if="node.type !== 'start'">
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用 NodeContextMenu 组件 - 更换类型 -->
|
||||
<NodeContextMenu v-model:visible="showChangeType"
|
||||
:position="changeTypeMenuPosition"
|
||||
:node="node"
|
||||
add-position="replace"
|
||||
@add="handleChangeType" />
|
||||
|
||||
<!-- 使用 NodeContextMenu 组件 - 新增子节点 -->
|
||||
<NodeContextMenu v-model:visible="showAddChild"
|
||||
:position="addChildMenuPosition"
|
||||
:node="node"
|
||||
add-position="right"
|
||||
@add="handleAddChild" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NodeContextMenu from './NodeContextMenu.vue'
|
||||
import { getNodeConfig } from './nodes/nodeTypes.ts'
|
||||
|
||||
export default {
|
||||
name: 'NodeMoreMenu',
|
||||
inject: ['parent'],
|
||||
components: {
|
||||
NodeContextMenu
|
||||
},
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
position: {
|
||||
type: Object,
|
||||
default: () => ({ x: 0, y: 0 })
|
||||
},
|
||||
node: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:visible'],
|
||||
data () {
|
||||
return {
|
||||
showChangeType: false,
|
||||
changeTypeMenuPosition: { x: 0, y: 0 },
|
||||
showAddChild: false,
|
||||
addChildMenuPosition: { x: 0, y: 0 }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
menuStyle () {
|
||||
return {
|
||||
left: `${this.position.x}px`,
|
||||
top: `${this.position.y}px`
|
||||
}
|
||||
},
|
||||
|
||||
// 判断是否可以添加子节点
|
||||
canAddChild () {
|
||||
if (!this.node || !this.parent) return false
|
||||
|
||||
// 结束节点不能添加子节点
|
||||
if (this.node.type === 'end') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取当前节点已有的连接数量
|
||||
const existingConnections = this.parent.connections?.filter((conn) => conn.sourceId === this.node.id) || []
|
||||
|
||||
// 如果是分支节点(switch 或 question),可以添加子节点
|
||||
if (['switch', 'question'].includes(this.node.type)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果不是分支节点且已经有连接,则不能添加子节点
|
||||
if (existingConnections.length > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 复制节点
|
||||
copyNode () {
|
||||
if (!this.node) return
|
||||
|
||||
// 创建节点的深拷贝
|
||||
const nodeCopy = JSON.parse(JSON.stringify(this.node))
|
||||
|
||||
// 生成新的唯一ID
|
||||
nodeCopy.id = `node_${Date.now()}`
|
||||
|
||||
// 设置新节点的位置(在原节点右下方20px处)
|
||||
nodeCopy.x = this.node.x + 20
|
||||
nodeCopy.y = this.node.y + 20
|
||||
|
||||
// 更新节点列表
|
||||
this.parent.nodes = [...this.parent.nodes, nodeCopy]
|
||||
|
||||
// 关闭菜单
|
||||
this.$emit('update:visible', false)
|
||||
},
|
||||
|
||||
// 删除节点
|
||||
deleteNode () {
|
||||
if (!this.node) return
|
||||
|
||||
// 删除节点的所有端点
|
||||
this.parent.jsPlumbInstance.removeAllEndpoints(this.node.id)
|
||||
|
||||
// 从节点列表中删除节点
|
||||
this.parent.nodes= this.parent.nodes.filter(n => n.id !== this.node.id)
|
||||
|
||||
// 关闭菜单
|
||||
this.$emit('update:visible', false)
|
||||
},
|
||||
|
||||
// 更换节点类型
|
||||
changeNodeType (newType) {
|
||||
if (!this.node || !newType) return
|
||||
|
||||
// 获取新节点类型的配置
|
||||
const nodeConfig = getNodeConfig(newType)
|
||||
|
||||
// 找到当前节点的索引
|
||||
const nodeIndex = this.parent.nodes.findIndex(n => n.id === this.node.id)
|
||||
if (nodeIndex === -1) return
|
||||
|
||||
// 保存原节点的位置和ID
|
||||
const { x, y, id } = this.node
|
||||
|
||||
// 创建新节点,保持原有的位置和ID
|
||||
const newNode = {
|
||||
...nodeConfig,
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
}
|
||||
|
||||
// 更新节点列表
|
||||
const newNodes = [...this.parent.nodes]
|
||||
newNodes[nodeIndex] = newNode
|
||||
this.parent.nodes = newNodes
|
||||
|
||||
// 关闭菜单
|
||||
this.$emit('update:visible', false)
|
||||
this.showChangeType = false
|
||||
},
|
||||
|
||||
handleShowChangeType (event) {
|
||||
// 计算子菜单位置,在父菜单右侧显示
|
||||
const menuEl = this.$el.querySelector('.more-menu')
|
||||
if (menuEl) {
|
||||
const rect = menuEl.getBoundingClientRect()
|
||||
// 检查右侧空间是否足够
|
||||
const rightSpace = window.innerWidth - rect.right
|
||||
const leftSpace = rect.left
|
||||
|
||||
// 如果右侧空间不足,且左侧空间足够,则显示在左侧
|
||||
if (rightSpace < 200 && leftSpace > 200) {
|
||||
this.changeTypeMenuPosition = {
|
||||
x: rect.left - 5, // 左侧偏移5px
|
||||
y: rect.top
|
||||
}
|
||||
} else {
|
||||
this.changeTypeMenuPosition = {
|
||||
x: rect.right + 5, // 右侧偏移5px
|
||||
y: rect.top
|
||||
}
|
||||
}
|
||||
}
|
||||
this.showChangeType = true
|
||||
// 阻止事件冒泡,防止触发外部点击事件
|
||||
event?.stopPropagation()
|
||||
},
|
||||
|
||||
handleChangeType (type) {
|
||||
this.changeNodeType(type)
|
||||
},
|
||||
|
||||
// 显示新增子节点菜单
|
||||
handleShowAddChild (event) {
|
||||
// 计算子菜单位置,在父菜单右侧显示
|
||||
const menuEl = this.$el.querySelector('.more-menu')
|
||||
if (menuEl) {
|
||||
const rect = menuEl.getBoundingClientRect()
|
||||
// 检查右侧空间是否足够
|
||||
const rightSpace = window.innerWidth - rect.right
|
||||
const leftSpace = rect.left
|
||||
|
||||
// 如果右侧空间不足,且左侧空间足够,则显示在左侧
|
||||
if (rightSpace < 200 && leftSpace > 200) {
|
||||
this.addChildMenuPosition = {
|
||||
x: rect.left - 5, // 左侧偏移5px
|
||||
y: rect.top
|
||||
}
|
||||
} else {
|
||||
this.addChildMenuPosition = {
|
||||
x: rect.right + 5, // 右侧偏移5px
|
||||
y: rect.top
|
||||
}
|
||||
}
|
||||
}
|
||||
this.showAddChild = true
|
||||
// 阻止事件冒泡,防止触发外部点击事件
|
||||
event?.stopPropagation()
|
||||
},
|
||||
|
||||
// 处理新增子节点
|
||||
handleAddChild (type) {
|
||||
if (!this.node || !this.parent || typeof this.parent.addNode !== 'function') return
|
||||
|
||||
// 模拟右键添加节点的流程
|
||||
this.parent.contextMenuNode = this.node
|
||||
this.parent.contextMenuAddPosition = 'right'
|
||||
this.parent.contextMenuPortIndex = 0
|
||||
|
||||
// 调用父组件的 addNode 方法
|
||||
this.parent.addNode(type)
|
||||
|
||||
// 关闭菜单
|
||||
this.$emit('update:visible', false)
|
||||
this.showAddChild = false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// 点击外部关闭菜单
|
||||
const handleClickOutside = (e) => {
|
||||
if (!this.$el.contains(e.target)) {
|
||||
this.$emit('update:visible', false)
|
||||
this.showChangeType = false
|
||||
this.showAddChild = false
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
// 保存引用以便在组件销毁时移除
|
||||
this.handleClickOutside = handleClickOutside
|
||||
},
|
||||
beforeUnmount () {
|
||||
// 移除事件监听器
|
||||
document.removeEventListener('click', this.handleClickOutside)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.more-menu {
|
||||
opacity: 0.9;
|
||||
position: fixed;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px 0;
|
||||
min-width: 160px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: rgb(71 84 103 / 1);
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.el-icon-arrow-right {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
203
src/views/knowledge/aiFlow/NodePanel.vue
Normal file
203
src/views/knowledge/aiFlow/NodePanel.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :title="node.name" :size="520" :show-close="false" direction="rtl" @close="$emit('close')">
|
||||
<template #header>
|
||||
<div class="px-5 py-4 w-full bg-white border-b border-gray-200">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="flex gap-2 items-center text-gray-800">
|
||||
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + node.type]" :name="`local-${node.type}`" />
|
||||
<input class="text-base font-bold bg-transparent border-none outline-none w-30" v-model="node.name" />
|
||||
<small class="text-xs text-gray-500">{{ node.id }}</small>
|
||||
</div>
|
||||
<!-- 关闭按钮 -->
|
||||
<div class="p-2 rounded transition-colors cursor-pointer hover:bg-gray-100" @click="$emit('close')">
|
||||
<el-icon><Close /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 描述区域 -->
|
||||
<div>
|
||||
<input
|
||||
v-model="node.description"
|
||||
class="w-full min-h-[30px] px-2 py-1 text-sm bg-gray-50 border border-gray-200 rounded outline-none transition-all hover:border-gray-300 hover:bg-white focus:border-blue-500 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.1)]"
|
||||
placeholder="添加描述..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="box-border flex flex-col h-full">
|
||||
<div class="overflow-y-auto flex-1 pb-4">
|
||||
<component :is="nodeConfig" :key="node.id" :node="node" />
|
||||
</div>
|
||||
|
||||
<!-- 下一个节点显示区域 -->
|
||||
<div class="px-5 py-4 bg-white border-t border-gray-200" v-if="node.type !== 'end'">
|
||||
<div class="flex justify-between mb-2">
|
||||
<div class="mb-2 text-sm font-bold text-gray-700">下一个节点</div>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 rounded-lg">
|
||||
<div class="relative" v-if="nextNodes.length">
|
||||
<div class="flex items-center relative min-h-[100px]">
|
||||
<!-- 当前节点 -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-gray-100 border border-gray-200 rounded min-w-[120px] h-10 relative mr-[80px]">
|
||||
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + node.type]" :name="`local-${node.type}`" />
|
||||
<span class="text-sm text-gray-700 truncate">{{ node.name }}</span>
|
||||
<!-- 连接线 -->
|
||||
<div class="absolute top-1/2 right-[-80px] w-[80px] h-[2px] bg-gray-200"></div>
|
||||
</div>
|
||||
|
||||
<!-- 分支连线和节点 -->
|
||||
<div class="flex relative flex-col flex-1 gap-8 pl-10 min-h-full">
|
||||
<!-- 垂直连接线 -->
|
||||
<div class="absolute left-0 top-5 h-[calc(100%-20px)] w-[2px] bg-gray-200"></div>
|
||||
|
||||
<div v-for="branch in nextNodes" :key="branch.node?.id" class="relative flex items-center gap-4 min-h-[40px]">
|
||||
<!-- 水平连接线 -->
|
||||
<div class="absolute top-1/2 left-[-40px] w-[20px] h-[2px] bg-gray-200"></div>
|
||||
|
||||
<!-- 分支节点 -->
|
||||
<template v-if="branch.node">
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-white border border-gray-200 rounded min-w-[120px] h-10 ml-[-80px]">
|
||||
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + branch.node.type]" :name="`local-${branch.node.type}`" />
|
||||
<span class="text-sm text-gray-700 truncate">{{ branch.node.name }}</span>
|
||||
</div>
|
||||
<el-button type="primary" size="small" link @click.stop="jumpToNextNode(branch.node)" class="flex gap-1 items-center">
|
||||
<el-icon><Right /></el-icon>
|
||||
跳转到节点
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="primary" plain size="small" class="w-[180px] h-9 justify-center gap-1 border-dashed">
|
||||
<el-icon><Plus /></el-icon>
|
||||
选择下一个节点
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-5 text-center text-gray-500 bg-white rounded-lg border border-gray-200 border-dashed">暂无下一个节点</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Close, Right, Plus } from '@element-plus/icons-vue';
|
||||
// 使用 import.meta.glob 自动导入所有面板组件
|
||||
const modules = import.meta.glob('./panels/*.vue', { eager: true });
|
||||
|
||||
export default {
|
||||
name: 'NodePanel',
|
||||
components: {
|
||||
Close,
|
||||
Right,
|
||||
Plus,
|
||||
},
|
||||
inject: ['parent'],
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
highlightedConnection: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 添加抽屉显示状态的计算属性
|
||||
drawerVisible: {
|
||||
get() {
|
||||
return this.visible;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:visible', value);
|
||||
},
|
||||
},
|
||||
nodeConfig() {
|
||||
// 从文件名中提取组件类型
|
||||
// 检查节点类型是否存在,并构建面板组件名称
|
||||
const panelName = this.node?.type ? `${this.node.type.charAt(0).toUpperCase()}${this.node.type.slice(1)}Panel` : '';
|
||||
// 查找对应的面板组件
|
||||
const panel = panelName ? Object.values(modules).find((module) => module.default.name === panelName) : {};
|
||||
return panel?.default;
|
||||
},
|
||||
// 获取下一个节点的逻辑
|
||||
nextNodes() {
|
||||
if (!this.parent?.connections || !this.node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取所有从当前节点出发的连接
|
||||
const nextConnections = this.parent.connections.filter((conn) => conn.sourceId === this.node.id);
|
||||
|
||||
// 将连接转换为节点信息
|
||||
return nextConnections.map((conn) => {
|
||||
const targetNode = this.parent.nodes.find((node) => node.id === conn.targetId);
|
||||
return {
|
||||
node: targetNode,
|
||||
condition: conn.condition || '默认分支',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
jumpToNextNode(node) {
|
||||
if (!node) return;
|
||||
|
||||
// 延迟执行以确保 DOM 已更新
|
||||
this.$nextTick(() => {
|
||||
// 获取工作流容器元素
|
||||
const workflowContainer = document.querySelector('.workflow-container');
|
||||
// 获取目标节点元素
|
||||
const nextNodeElement = workflowContainer?.querySelector(`#${node.id}`);
|
||||
|
||||
if (nextNodeElement && workflowContainer) {
|
||||
// 计算目标节点相对于容器的位置
|
||||
const containerRect = workflowContainer.getBoundingClientRect();
|
||||
const nodeRect = nextNodeElement.getBoundingClientRect();
|
||||
|
||||
// 计算滚动位置,使节点居中显示
|
||||
const scrollLeft = nodeRect.left - containerRect.left - (containerRect.width - nodeRect.width) / 2;
|
||||
const scrollTop = nodeRect.top - containerRect.top - (containerRect.height - nodeRect.height) / 2;
|
||||
// 平滑滚动到目标位置
|
||||
workflowContainer.scrollTo({
|
||||
left: workflowContainer.scrollLeft + scrollLeft,
|
||||
top: workflowContainer.scrollTop + scrollTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
// 选中节点
|
||||
this.parent.selectNode(node);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 保留节点图标样式,这部分可能需要单独处理 */
|
||||
:deep(.highlight-connection) {
|
||||
stroke: var(--el-color-primary) !important;
|
||||
stroke-width: 2px !important;
|
||||
animation: connectionPulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes connectionPulse {
|
||||
0% {
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
50% {
|
||||
stroke-opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
336
src/views/knowledge/aiFlow/Run.vue
Normal file
336
src/views/knowledge/aiFlow/Run.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<div class="workflow-designer">
|
||||
<!-- 添加未发布遮罩组件 -->
|
||||
<UnpublishedMask :visible="!form.enabled" />
|
||||
|
||||
<!-- 执行面板 -->
|
||||
<div class="execution-panel"
|
||||
v-if="form.enabled">
|
||||
<div class="panel-header">
|
||||
<h3>流程运行{{ id }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<!-- 参数输入区域 -->
|
||||
<div class="left-panel">
|
||||
<div class="variable-inputs">
|
||||
<div v-for="(param, index) in startNodeParams"
|
||||
:key="index"
|
||||
class="input-item">
|
||||
<div class="input-label"
|
||||
:class="{ 'required': param.required }">
|
||||
{{ param.name }}
|
||||
</div>
|
||||
<div class="input-value">
|
||||
<input v-if="param.inputType === 'input'"
|
||||
v-model="param.value"
|
||||
class="param-input"
|
||||
:disabled="isRunning"
|
||||
:class="{ 'error': showError && param.required && !param.value }"
|
||||
:placeholder="'请输入' + param.name" />
|
||||
<input v-else-if="param.inputType === 'number'"
|
||||
type="number"
|
||||
v-model.number="param.value"
|
||||
class="param-input"
|
||||
:disabled="isRunning"
|
||||
:class="{ 'error': showError && param.required && !param.value }"
|
||||
:placeholder="'请输入' + param.name" />
|
||||
<textarea v-else-if="param.inputType === 'textarea'"
|
||||
v-model="param.value"
|
||||
class="param-textarea"
|
||||
:disabled="isRunning"
|
||||
:class="{ 'error': showError && param.required && !param.value }"
|
||||
:placeholder="'请输入' + param.name"
|
||||
rows="3"></textarea>
|
||||
<select v-else-if="param.inputType === 'select'"
|
||||
v-model="param.value"
|
||||
class="param-select"
|
||||
:disabled="isRunning"
|
||||
:class="{ 'error': showError && param.required && !param.value }">
|
||||
<option value="">请选择{{ param.name }}</option>
|
||||
<option v-for="option in param.options"
|
||||
:key="option.value"
|
||||
:value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button type="primary"
|
||||
class="run-btn"
|
||||
:disabled="isRunning"
|
||||
@click="handleParamRun">
|
||||
{{ isRunning ? '运行中...' : '运行' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 执行状态和结果区域 -->
|
||||
<div class="execution-detail"
|
||||
v-if="executionNodes.length">
|
||||
<div class="detail-card">
|
||||
<div class="detail-row">
|
||||
<div class="detail-item">
|
||||
<div class="label">状态</div>
|
||||
<div class="value"
|
||||
:class="executionStatus.class">
|
||||
{{ executionStatus.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">运行时间</div>
|
||||
<div class="value">{{ formatTotalTime(executionTime) }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">总 TOKEN 数</div>
|
||||
<div class="value">{{ totalTokens }} Tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<!-- 执行进度和结果区域 -->
|
||||
<node-list :nodes="executionNodes"
|
||||
@end="handleEnd"
|
||||
v-if="executionNodes.length" />
|
||||
|
||||
<!-- 最终执行结果 -->
|
||||
<div class="execution-result"
|
||||
v-if="executionResult">
|
||||
<h4>执行结果</h4>
|
||||
<pre>{{ JSON.stringify(executionResult, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Loading, Check, CircleClose, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { getObj } from '/@/api/knowledge/aiFlow';
|
||||
import NodeList from './components/NodeList.vue'
|
||||
import NodeCommon from './mixins/Node.ts'
|
||||
import UnpublishedMask from './components/UnpublishedMask.vue'
|
||||
|
||||
export default {
|
||||
name: 'WorkflowRun',
|
||||
mixins: [NodeCommon],
|
||||
components: {
|
||||
Loading,
|
||||
Check,
|
||||
CircleClose,
|
||||
ArrowRight,
|
||||
NodeList,
|
||||
UnpublishedMask
|
||||
},
|
||||
provide () {
|
||||
return {
|
||||
parent: this,
|
||||
nodes: this.nodes,
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
form: { enabled: true },
|
||||
executionNodes: [],
|
||||
executionResult: null,
|
||||
executionTime: 0,
|
||||
totalTokens: 0,
|
||||
startNodeParams: [], // 添加开始节点参数
|
||||
showError: false,
|
||||
isRunning: false // 添加运行状态控制
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
executionStatus () {
|
||||
const lastNode = this.executionNodes[this.executionNodes.length - 1]
|
||||
if (!lastNode) return { text: '等待中', class: 'status-pending' }
|
||||
|
||||
const statusMap = {
|
||||
'running': { text: '运行中', class: 'status-running' },
|
||||
'success': { text: '成功', class: 'status-success' },
|
||||
'error': { text: '失败', class: 'status-error' },
|
||||
'skipped': { text: '已跳过', class: 'status-skipped' }
|
||||
}
|
||||
|
||||
return statusMap[lastNode.status] || { text: '等待中', class: 'status-pending' }
|
||||
},
|
||||
},
|
||||
created () {
|
||||
this.loadFromStorage()
|
||||
},
|
||||
unmounted() {
|
||||
this.resetConversation();
|
||||
},
|
||||
methods: {
|
||||
// 修改 loadFromStorage 方法
|
||||
async loadFromStorage () {
|
||||
try {
|
||||
const res = await getObj(this.id)
|
||||
this.form = res.data.data;
|
||||
const { dsl = '{}' } = this.form
|
||||
const data = JSON.parse(dsl)
|
||||
this.nodes = data.nodes || []
|
||||
this.connections = data.connections || []
|
||||
this.env = data.env || []
|
||||
this.handleRunClick()
|
||||
} catch (error) {
|
||||
console.error('加载工作流失败:', error)
|
||||
}
|
||||
},
|
||||
|
||||
formatTotalTime (time) {
|
||||
if (!time) return '0ms'
|
||||
return `${Number(time).toFixed(3)}ms`
|
||||
},
|
||||
handleParamRun () {
|
||||
const hasError = this.startNodeParams.some(param => param.required && !param.value)
|
||||
this.showError = hasError
|
||||
|
||||
if (hasError) {
|
||||
return
|
||||
}
|
||||
|
||||
this.runWorkflow(this.startNodeParams)
|
||||
this.showError = false
|
||||
},
|
||||
handleEnd (status) {
|
||||
this.isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './styles/flow.scss';
|
||||
.workflow-designer {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: #f8f9fc;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.execution-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: white;
|
||||
border-left: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
color: #333;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
background: #f8f9fc;
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
padding: 15px;
|
||||
width: 350px;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-right: 1px solid #dcdfe6;
|
||||
}
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.execution-detail {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #f0f9eb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e1f3d8;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
.detail-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid rgba(225, 243, 216, 0.8);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #67c23a;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
&.status-success {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.status-error {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.status-running {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.execution-result {
|
||||
margin-top: 20px;
|
||||
|
||||
h4 {
|
||||
margin-bottom: 10px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
271
src/views/knowledge/aiFlow/Toolbar.vue
Normal file
271
src/views/knowledge/aiFlow/Toolbar.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="workflow-toolbar">
|
||||
<div class="toolbar-box">
|
||||
<!-- 主工具栏分组 -->
|
||||
<div class="toolbar-group">
|
||||
<el-tooltip content="检查工作流" placement="bottom">
|
||||
<el-button class="toolbar-btn" @click="handleCheck">
|
||||
<el-icon>
|
||||
<List />
|
||||
</el-icon>
|
||||
<span v-if="validation && !validation.isValid" class="badge">
|
||||
{{ validation.errors.length }}
|
||||
</span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="环境设置" placement="bottom">
|
||||
<el-button class="toolbar-btn" @click="handleEnvSettings">
|
||||
<el-icon>
|
||||
<Setting />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="数据结构" placement="bottom">
|
||||
<el-button class="toolbar-btn" @click="handlePreview">
|
||||
<el-icon>
|
||||
<Document />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="API 信息" placement="bottom">
|
||||
<el-button class="toolbar-btn" @click="showApi = true">
|
||||
<el-icon>
|
||||
<Connection />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<template v-if="parent.isChatFlow">
|
||||
<el-tooltip content="开场白设置" placement="bottom">
|
||||
<el-button class="toolbar-btn" @click="showGreetingEditor = true">
|
||||
<el-icon>
|
||||
<ChatDotRound />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 运行按钮 -->
|
||||
<el-button class="run-btn" :disabled="!canRun" icon="video-play" @click="handleCommand('run')"> 运行 </el-button>
|
||||
<!-- 发布按钮 -->
|
||||
<el-dropdown @command="handleCommand">
|
||||
<el-button class="publish-btn" type="primary" icon="circle-check" @click="handleSave"> 保存 </el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="share" v-if="parent.isChatFlow">
|
||||
<el-icon><Share /></el-icon>
|
||||
分享流程
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="runs">
|
||||
<el-icon><Upload /></el-icon>
|
||||
保存并发布
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<el-tooltip content="关闭" placement="bottom">
|
||||
<el-button class="close-btn" @click="handleClose">
|
||||
<el-icon>
|
||||
<CloseBold />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 面板组件 -->
|
||||
<json-preview-panel v-model="showPreview" />
|
||||
<checkListPanel v-show="showCheck" v-model:validation="validation" @close="showCheck = false" />
|
||||
<envSettingsPanel v-model="showEnv" @close="closeEnvPanel" />
|
||||
<api-panel v-model="showApi" :flow-id="parent.id" />
|
||||
<!-- 添加开场白编辑器组件 -->
|
||||
<greeting-editor v-model="showGreetingEditor" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { putObj } from '/@/api/knowledge/aiFlow';
|
||||
import { List, Setting, Upload, Document, Share, ChatDotRound, CloseBold, Connection } from '@element-plus/icons-vue';
|
||||
import JsonPreviewPanel from './JsonPreviewPanel.vue';
|
||||
import CheckListPanel from './CheckListPanel.vue';
|
||||
import EnvSettingsPanel from './EnvSettingsPanel.vue';
|
||||
import ApiPanel from './ApiPanel.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import GreetingEditor from './GreetingEditor.vue';
|
||||
|
||||
export default {
|
||||
name: 'Toolbar',
|
||||
inject: ['parent'],
|
||||
components: {
|
||||
CheckListPanel,
|
||||
EnvSettingsPanel,
|
||||
JsonPreviewPanel,
|
||||
GreetingEditor,
|
||||
ApiPanel,
|
||||
Upload,
|
||||
Share,
|
||||
List,
|
||||
Setting,
|
||||
Document,
|
||||
Connection,
|
||||
ChatDotRound,
|
||||
CloseBold,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
|
||||
const update = async (data) => {
|
||||
return putObj(data);
|
||||
};
|
||||
|
||||
return { router, update };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showEmbed: false,
|
||||
showGreetingEditor: false,
|
||||
showApi: false,
|
||||
validation: {
|
||||
errors: [],
|
||||
isValid: false,
|
||||
},
|
||||
showEnv: false,
|
||||
showPreview: false,
|
||||
showCheck: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canRun() {
|
||||
return this.validation && this.validation.isValid;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handlePreview() {
|
||||
this.showPreview = !this.showPreview;
|
||||
},
|
||||
async handleSave(enabled) {
|
||||
this.showPreview = false;
|
||||
|
||||
const { nodes, connections } = this.parent;
|
||||
// 构建完整的工作流状态
|
||||
const state = {
|
||||
nodes,
|
||||
connections,
|
||||
env: this.parent.env,
|
||||
greeting: this.parent.dsl.greetingText,
|
||||
questions: this.parent.dsl.questions,
|
||||
};
|
||||
|
||||
try {
|
||||
// 构建工作流DSL数据
|
||||
const workflowData = {
|
||||
id: this.parent.id,
|
||||
dsl: JSON.stringify(state),
|
||||
enabled: enabled,
|
||||
};
|
||||
|
||||
// 调用API保存工作流
|
||||
await this.update(workflowData);
|
||||
|
||||
this.$message.success('保存成功');
|
||||
} catch (error) {
|
||||
this.$message.error('保存失败:' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
handleCheck() {
|
||||
this.showCheck = !this.showCheck;
|
||||
},
|
||||
handleRun() {
|
||||
this.$emit('run');
|
||||
},
|
||||
handleEnvSettings() {
|
||||
this.showEnv = true;
|
||||
},
|
||||
handleWorkflowRun() {
|
||||
this.handleSave('1');
|
||||
},
|
||||
// 关闭环境设置面板
|
||||
closeEnvPanel() {
|
||||
this.showEnv = false;
|
||||
},
|
||||
// 添加下拉菜单命令处理方法
|
||||
handleCommand(command) {
|
||||
switch (command) {
|
||||
case 'save':
|
||||
this.handleSave();
|
||||
break;
|
||||
case 'share':
|
||||
this.showEmbed = true;
|
||||
break;
|
||||
case 'run':
|
||||
this.handleRun();
|
||||
break;
|
||||
case 'runs':
|
||||
this.handleWorkflowRun();
|
||||
break;
|
||||
}
|
||||
},
|
||||
// 添加关闭方法
|
||||
handleClose() {
|
||||
this.$router.go(-1); // 返回上一页
|
||||
},
|
||||
},
|
||||
emits: ['run'],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.workflow-toolbar {
|
||||
padding: 5px 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
z-index: 100;
|
||||
}
|
||||
.toolbar-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
|
||||
.toolbar-btn {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
margin-top: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0 6px;
|
||||
background: #ff4d4f;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
</style>
|
||||
321
src/views/knowledge/aiFlow/ZoomControl.vue
Normal file
321
src/views/knowledge/aiFlow/ZoomControl.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="zoom-control">
|
||||
<el-button-group>
|
||||
<el-button @click="handleZoomIn" icon="zoom-in" size="small"></el-button>
|
||||
<el-button @click="handleZoomOut" icon="zoom-out" size="small"></el-button>
|
||||
<el-button @click="handleResetZoom" icon="refresh" size="small"></el-button>
|
||||
<el-button @click="handleArrangeNodes" icon="sort" size="small" title="一键整理"></el-button>
|
||||
</el-button-group>
|
||||
<span class="zoom-text">{{ Math.round(zoom * 100) }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ZoomControl',
|
||||
inject: ['parent'], // 注入父组件引用
|
||||
props: {
|
||||
zoom: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
minZoom: {
|
||||
type: Number,
|
||||
default: 0.1,
|
||||
},
|
||||
maxZoom: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
zoomStep: {
|
||||
type: Number,
|
||||
default: 0.1,
|
||||
},
|
||||
position: {
|
||||
type: Object,
|
||||
default: () => ({ x: 0, y: 0 }),
|
||||
},
|
||||
},
|
||||
emits: ['update:zoom', 'updatePosition'],
|
||||
methods: {
|
||||
// 放大
|
||||
handleZoomIn() {
|
||||
const newZoom = Math.min(this.maxZoom, this.zoom + this.zoomStep);
|
||||
this.$emit('update:zoom', newZoom);
|
||||
},
|
||||
|
||||
// 缩小
|
||||
handleZoomOut() {
|
||||
const newZoom = Math.max(this.minZoom, this.zoom - this.zoomStep);
|
||||
this.$emit('update:zoom', newZoom);
|
||||
},
|
||||
|
||||
// 重置缩放
|
||||
handleResetZoom() {
|
||||
this.$emit('update:zoom', 1);
|
||||
this.$emit('updatePosition', { x: 0, y: 0 });
|
||||
},
|
||||
|
||||
/**
|
||||
* 一键整理节点
|
||||
* 按照工作流程图从左到右、从上到下的布局方式排列节点
|
||||
*/
|
||||
handleArrangeNodes() {
|
||||
if (!this.parent?.nodes?.length) return;
|
||||
|
||||
const HORIZONTAL_GAP = 300; // 节点之间的水平间距
|
||||
const VERTICAL_GAP = 250; // 节点之间的垂直间距
|
||||
const START_X = 200; // 起始X坐标
|
||||
const START_Y = 300; // 起始Y坐标
|
||||
const visited = new Set(); // 记录已访问的节点
|
||||
const levels = new Map(); // 记录每个节点的层级
|
||||
const positions = new Map(); // 记录节点位置
|
||||
|
||||
// 1. 找到所有起始节点(入度为0的节点)
|
||||
const startNodes = this.parent.nodes.filter((node) => !this.parent.connections.some((conn) => conn.targetId === node.id));
|
||||
|
||||
// 2. 使用 BFS 计算每个节点的层级
|
||||
const queue = startNodes.map((node) => ({ node, level: 0 }));
|
||||
while (queue.length > 0) {
|
||||
const { node, level } = queue.shift();
|
||||
if (visited.has(node.id)) continue;
|
||||
|
||||
visited.add(node.id);
|
||||
if (!levels.has(level)) {
|
||||
levels.set(level, []);
|
||||
}
|
||||
levels.get(level).push(node);
|
||||
|
||||
// 获取当前节点的所有后继节点
|
||||
const nextNodes = this.parent.connections
|
||||
.filter((conn) => conn.sourceId === node.id)
|
||||
.map((conn) => this.parent.nodes.find((n) => n.id === conn.targetId))
|
||||
.filter(Boolean);
|
||||
|
||||
// 将后继节点加入队列,层级+1
|
||||
nextNodes.forEach((nextNode) => {
|
||||
if (!visited.has(nextNode.id)) {
|
||||
queue.push({ node: nextNode, level: level + 1 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 计算每个节点的新位置
|
||||
let maxNodesInLevel = 0;
|
||||
levels.forEach((nodes) => {
|
||||
maxNodesInLevel = Math.max(maxNodesInLevel, nodes.length);
|
||||
});
|
||||
|
||||
// 4. 为每个层级的节点分配位置
|
||||
levels.forEach((nodes, level) => {
|
||||
const levelWidth = HORIZONTAL_GAP;
|
||||
const levelStartY = START_Y + (maxNodesInLevel * VERTICAL_GAP - nodes.length * VERTICAL_GAP) / 2;
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const x = START_X + level * levelWidth;
|
||||
const y = levelStartY + index * VERTICAL_GAP;
|
||||
positions.set(node.id, { x, y });
|
||||
});
|
||||
});
|
||||
|
||||
// 5. 使用动画更新节点位置
|
||||
const duration = 500; // 动画持续时间(毫秒)
|
||||
const startTime = performance.now();
|
||||
const startPositions = new Map(this.parent.nodes.map((node) => [node.id, { x: node.x, y: node.y }]));
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// 使用缓动函数使动画更自然
|
||||
const easeProgress = this.easeInOutCubic(progress);
|
||||
|
||||
// 更新所有节点的位置
|
||||
this.parent.nodes.forEach((node) => {
|
||||
if (positions.has(node.id)) {
|
||||
const startPos = startPositions.get(node.id);
|
||||
const targetPos = positions.get(node.id);
|
||||
|
||||
node.x = startPos.x + (targetPos.x - startPos.x) * easeProgress;
|
||||
node.y = startPos.y + (targetPos.y - startPos.y) * easeProgress;
|
||||
}
|
||||
});
|
||||
|
||||
// 继续动画或结束
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
// 动画结束后重新计算连线
|
||||
this.parent.jsPlumbInstance?.repaintEverything();
|
||||
|
||||
// 等待连线重绘完成后再居中显示
|
||||
setTimeout(() => {
|
||||
this.calculateFitZoom();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
},
|
||||
|
||||
/**
|
||||
* 缓动函数
|
||||
* @param {number} t - 进度值 (0-1)
|
||||
* @returns {number} 缓动后的进度值
|
||||
*/
|
||||
easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算合适的缩放比例,使所有节点都在可视区域内
|
||||
*/
|
||||
calculateFitZoom() {
|
||||
if (!this.parent?.nodes?.length) return 1;
|
||||
|
||||
// 获取工作区容器
|
||||
const container = document.querySelector('.workflow-container');
|
||||
if (!container) return 1;
|
||||
|
||||
// 计算节点的边界
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
this.parent.nodes.forEach((node) => {
|
||||
minX = Math.min(minX, node.x);
|
||||
minY = Math.min(minY, node.y);
|
||||
maxX = Math.max(maxX, node.x + 200); // 假设节点宽度为 200
|
||||
maxY = Math.max(maxY, node.y + 100); // 假设节点高度为 100
|
||||
});
|
||||
|
||||
// 添加边距
|
||||
const PADDING = 100; // 增加边距,让画面更加宽松
|
||||
minX -= PADDING;
|
||||
minY -= PADDING;
|
||||
maxX += PADDING;
|
||||
maxY += PADDING;
|
||||
|
||||
// 计算内容和容器的宽高比
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight;
|
||||
|
||||
// 计算合适的缩放比例,确保完整显示
|
||||
const scaleX = containerWidth / contentWidth;
|
||||
const scaleY = containerHeight / contentHeight;
|
||||
const scale = Math.min(scaleX, scaleY, 1); // 不超过 1 倍
|
||||
|
||||
// 计算居中位置,考虑缩放因素
|
||||
const centerX = (maxX + minX) / 2;
|
||||
const centerY = (maxY + minY) / 2;
|
||||
|
||||
// 计算平移位置,使内容在容器中居中
|
||||
const translateX = containerWidth / 2 - centerX * scale;
|
||||
const translateY = containerHeight / 2 - centerY * scale;
|
||||
|
||||
// 使用动画平滑过渡到新的位置和缩放
|
||||
const duration = 500;
|
||||
const startTime = performance.now();
|
||||
const startZoom = this.zoom;
|
||||
const startPos = { ...this.position };
|
||||
const targetPos = {
|
||||
x: translateX / scale,
|
||||
y: translateY / scale,
|
||||
};
|
||||
|
||||
const animateView = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easeProgress = this.easeInOutCubic(progress);
|
||||
|
||||
// 更新缩放和位置
|
||||
const currentZoom = startZoom + (scale - startZoom) * easeProgress;
|
||||
const currentX = startPos.x + (targetPos.x - startPos.x) * easeProgress;
|
||||
const currentY = startPos.y + (targetPos.y - startPos.y) * easeProgress;
|
||||
|
||||
this.$emit('update:zoom', currentZoom);
|
||||
this.$emit('updatePosition', { x: currentX, y: currentY });
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animateView);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animateView);
|
||||
return scale;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zoom-control {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
padding: 4px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.el-button-group {
|
||||
.el-button {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
// 移除按钮文字
|
||||
span:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 添加按钮提示效果
|
||||
&[title] {
|
||||
position: relative;
|
||||
|
||||
&:hover::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.zoom-text {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
138
src/views/knowledge/aiFlow/components/ChatDetail.vue
Normal file
138
src/views/knowledge/aiFlow/components/ChatDetail.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<!-- 对话详情抽屉 -->
|
||||
<el-drawer v-model="visible"
|
||||
title="对话详情"
|
||||
size="500"
|
||||
:before-close="handleClose"
|
||||
class="chat-detail-drawer">
|
||||
<div v-loading="loading">
|
||||
<ChatMessage :messages="messages"
|
||||
v-if="messages.length > 0"
|
||||
ref="messageContainer" />
|
||||
<el-empty v-else
|
||||
description="暂无对话记录">
|
||||
</el-empty>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { marked } from 'marked'
|
||||
import { Cpu } from '@element-plus/icons-vue'
|
||||
import ChatMessage from './ChatMessage.vue'
|
||||
export default {
|
||||
name: 'ChatDetail',
|
||||
components: {
|
||||
Cpu,
|
||||
ChatMessage
|
||||
},
|
||||
|
||||
props: {
|
||||
// 对话ID
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
// 是否显示弹窗
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
messages: []
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
visible: {
|
||||
get () {
|
||||
return this.modelValue
|
||||
},
|
||||
set (val) {
|
||||
this.$emit('update:modelValue', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
conversationId: {
|
||||
handler (val) {
|
||||
if (val) {
|
||||
this.loadMessages()
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 加载消息列表
|
||||
async loadMessages () {
|
||||
if (!this.conversationId) return
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const res = null
|
||||
this.messages = res.data.data.records || []
|
||||
} catch (error) {
|
||||
console.error('加载消息失败:', error)
|
||||
this.$message.error('加载消息失败')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 解析 Markdown
|
||||
parseMarkdown (text) {
|
||||
if (!text) return ''
|
||||
return marked(text)
|
||||
},
|
||||
|
||||
// 关闭弹窗
|
||||
handleClose () {
|
||||
this.visible = false
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理消息编辑事件
|
||||
* @param {Object} data - 包含索引和更新后消息的对象
|
||||
*/
|
||||
handleMessageEdited(data) {
|
||||
// 更新消息数组中的消息
|
||||
if (data && data.index >= 0 && data.index < this.messages.length) {
|
||||
this.messages[data.index] = data.message;
|
||||
|
||||
// 发送更新事件给父组件
|
||||
this.$emit('message-updated', this.messages);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理消息删除事件
|
||||
* @param {Number} index - 要删除的消息索引
|
||||
*/
|
||||
handleMessageDeleted(index) {
|
||||
// 从消息数组中删除消息
|
||||
if (index >= 0 && index < this.messages.length) {
|
||||
this.messages.splice(index, 1);
|
||||
|
||||
// 发送更新事件给父组件
|
||||
this.$emit('message-updated', this.messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.chat-detail-drawer {
|
||||
.el-drawer__body{
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
570
src/views/knowledge/aiFlow/components/ChatMessage.vue
Normal file
570
src/views/knowledge/aiFlow/components/ChatMessage.vue
Normal file
@@ -0,0 +1,570 @@
|
||||
<template>
|
||||
<div class="messages">
|
||||
<div v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
class="message"
|
||||
:class="[msg.role]"
|
||||
@contextmenu.prevent="showContextMenu($event, msg, index)">
|
||||
<div class="avatar">
|
||||
<img :src="msg.role === 'user' ? currentUserAvatar : botAvatar"
|
||||
alt="avatar" />
|
||||
</div>
|
||||
<div style="width: 100%;">
|
||||
<!-- 消息时间显示 - 移到content外面 -->
|
||||
<div class="time">{{ getMessageTime(msg) }}</div>
|
||||
<div class="content">
|
||||
<!-- 思考内容区域,添加可折叠功能,默认展开 -->
|
||||
<div class="collapsible_wrapper"
|
||||
v-if="msg.reasoning_content">
|
||||
<div class="collapsible_tag"
|
||||
@click="toggleContent(index, 'reasoning')">
|
||||
<el-icon class="collapsible-icon"
|
||||
:class="{ 'is-rotate': contentVisible[index]?.reasoning !== false }">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
<span>深度思考</span>
|
||||
</div>
|
||||
<div class="collapsible_content"
|
||||
v-show="contentVisible[index]?.reasoning !== false">
|
||||
{{ msg.reasoning_content }}
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="msg.content">
|
||||
<div v-html="parseMarkdown(msg.content)"></div>
|
||||
<div class="questions"
|
||||
v-if="msg.questions">
|
||||
<el-tag v-for="(question, qIndex) in msg.questions"
|
||||
:key="qIndex"
|
||||
type="primary"
|
||||
@click="handleQuestionClick(question.text)">
|
||||
{{ question.text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="msg.nodes"
|
||||
class="collapsible_wrapper">
|
||||
<div class="collapsible_tag"
|
||||
@click="toggleContent(index, 'nodes')">
|
||||
<el-icon class="collapsible-icon"
|
||||
:class="{ 'is-rotate': contentVisible[index]?.nodes !== false }">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
<span>执行步骤</span>
|
||||
</div>
|
||||
<div class="collapsible_content"
|
||||
v-show="contentVisible[index]?.nodes !== false">
|
||||
<node-list :nodes="msg.nodes" />
|
||||
</div>
|
||||
<div v-if="msg.result"
|
||||
v-html="parseMarkdown(msg.result)">
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="loading && messages.indexOf(msg) === messages.length - 1">
|
||||
<div class="typing-indicator">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右键菜单 -->
|
||||
<el-dialog v-model="editDialogVisible"
|
||||
title="编辑消息"
|
||||
width="50%"
|
||||
:before-close="handleEditDialogClose">
|
||||
<el-input v-model="editingContent"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="请输入消息内容" />
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleEditDialogClose">取消</el-button>
|
||||
<el-button type="primary"
|
||||
@click="saveEditedMessage">确认</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 自定义右键菜单 -->
|
||||
<div v-show="contextMenuVisible"
|
||||
class="context-menu"
|
||||
:style="{ top: contextMenuTop + 'px', left: contextMenuLeft + 'px' }">
|
||||
<div class="context-menu-item"
|
||||
@click="copyMessage">
|
||||
<el-icon>
|
||||
<Document />
|
||||
</el-icon>
|
||||
<span>复制</span>
|
||||
</div>
|
||||
<div class="context-menu-item"
|
||||
@click="editMessage">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
<div class="context-menu-item"
|
||||
@click="deleteMessage">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</div>
|
||||
<div class="context-menu-item"
|
||||
@click="speakMessage">
|
||||
<el-icon>
|
||||
<Microphone />
|
||||
</el-icon>
|
||||
<span>朗读</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
// 按需导入常用的语言
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import scss from 'highlight.js/lib/languages/scss'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
// 导入暗色主题样式
|
||||
import 'highlight.js/styles/atom-one-dark.css'
|
||||
// 导入Element Plus图标
|
||||
import { ArrowDown, Document, Edit, Delete, Microphone } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import NodeList from '/@/views/knowledge/aiFlow/components/NodeList.vue'
|
||||
import {dateTimeStr} from "/@/utils/formatTime";
|
||||
|
||||
// 定义组件接收的props
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
currentUserAvatar: {
|
||||
type: String,
|
||||
default: '/img/chat/icon.png'
|
||||
},
|
||||
botAvatar: {
|
||||
type: String,
|
||||
default: '/img/chat/chatgpt.png'
|
||||
}
|
||||
})
|
||||
|
||||
// 定义组件触发的事件
|
||||
const emit = defineEmits(['item-click', 'change'])
|
||||
|
||||
// 注册语言
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
hljs.registerLanguage('typescript', typescript)
|
||||
hljs.registerLanguage('python', python)
|
||||
hljs.registerLanguage('java', java)
|
||||
hljs.registerLanguage('xml', xml)
|
||||
hljs.registerLanguage('css', css)
|
||||
hljs.registerLanguage('scss', scss)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('bash', bash)
|
||||
hljs.registerLanguage('markdown', markdown)
|
||||
|
||||
// 配置marked的代码高亮选项
|
||||
onMounted(() => {
|
||||
marked.setOptions({
|
||||
highlight: function (code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(code, { language: lang }).value
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return code
|
||||
}
|
||||
}
|
||||
return hljs.highlightAuto(code).value
|
||||
},
|
||||
breaks: true, // 支持换行符
|
||||
gfm: true, // 启用GitHub风格Markdown
|
||||
sanitize: false, // 允许HTML标签以支持代码高亮
|
||||
langPrefix: 'hljs language-', // 添加代码块的class前缀
|
||||
})
|
||||
})
|
||||
|
||||
// 使用reactive创建响应式对象,用于跟踪内容的显示状态
|
||||
const contentVisible = reactive({})
|
||||
|
||||
// 右键菜单相关状态
|
||||
const contextMenuVisible = ref(false)
|
||||
const contextMenuTop = ref(0)
|
||||
const contextMenuLeft = ref(0)
|
||||
const currentMessage = ref(null)
|
||||
const currentMessageIndex = ref(-1)
|
||||
|
||||
// 编辑对话框相关状态
|
||||
const editDialogVisible = ref(false)
|
||||
const editingContent = ref('')
|
||||
|
||||
/**
|
||||
* 切换内容的显示/隐藏状态
|
||||
* @param {number} index - 消息索引
|
||||
* @param {string} type - 内容类型 ('reasoning' 或 'nodes')
|
||||
*/
|
||||
const toggleContent = (index, type) => {
|
||||
if (!contentVisible[index]) {
|
||||
contentVisible[index] = {}
|
||||
}
|
||||
contentVisible[index][type] = contentVisible[index][type] === false ? true : false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息的时间
|
||||
* @param {Object} msg - 消息对象
|
||||
* @returns {string} - 格式化后的时间字符串
|
||||
*/
|
||||
const getMessageTime = (msg) => {
|
||||
// 如果消息对象中已有时间属性,则直接使用
|
||||
if (msg.time) {
|
||||
|
||||
return parseDate(msg.time,dateTimeStr)
|
||||
}
|
||||
// 否则生成当前时间并赋值给消息对象
|
||||
const currentTime = parseDate(new Date(),dateTimeStr)
|
||||
// 为消息对象添加时间属性
|
||||
msg.time = new Date().toISOString()
|
||||
return currentTime
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示右键菜单
|
||||
* @param {Event} event - 鼠标事件对象
|
||||
* @param {Object} msg - 消息对象
|
||||
* @param {number} index - 消息索引
|
||||
*/
|
||||
const showContextMenu = (event, msg, index) => {
|
||||
// 阻止默认右键菜单
|
||||
event.preventDefault()
|
||||
// 设置菜单位置
|
||||
contextMenuTop.value = event.clientY
|
||||
contextMenuLeft.value = event.clientX
|
||||
// 保存当前消息和索引
|
||||
currentMessage.value = msg
|
||||
currentMessageIndex.value = index
|
||||
// 显示菜单
|
||||
contextMenuVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏右键菜单
|
||||
*/
|
||||
const hideContextMenu = () => {
|
||||
contextMenuVisible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制消息内容到剪贴板
|
||||
*/
|
||||
const copyMessage = () => {
|
||||
if (!currentMessage.value) return
|
||||
|
||||
// 获取要复制的文本内容
|
||||
const textToCopy = currentMessage.value.content ||
|
||||
currentMessage.value.reasoning_content ||
|
||||
(currentMessage.value.result || '')
|
||||
|
||||
// 使用Clipboard API复制文本
|
||||
navigator.clipboard.writeText(textToCopy)
|
||||
.then(() => {
|
||||
ElMessage.success('复制成功')
|
||||
})
|
||||
.catch(err => {
|
||||
ElMessage.error('复制失败: ' + err)
|
||||
})
|
||||
|
||||
// 隐藏菜单
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开编辑对话框
|
||||
*/
|
||||
const editMessage = () => {
|
||||
if (!currentMessage.value) return
|
||||
|
||||
// 设置编辑内容
|
||||
editingContent.value = currentMessage.value.content ||
|
||||
currentMessage.value.reasoning_content ||
|
||||
(currentMessage.value.result || '')
|
||||
|
||||
// 显示编辑对话框
|
||||
editDialogVisible.value = true
|
||||
|
||||
// 隐藏菜单
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭编辑对话框
|
||||
*/
|
||||
const handleEditDialogClose = () => {
|
||||
editDialogVisible.value = false
|
||||
editingContent.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存编辑后的消息
|
||||
*/
|
||||
const saveEditedMessage = () => {
|
||||
if (currentMessageIndex.value === -1 || !currentMessage.value) return
|
||||
|
||||
// 更新时间
|
||||
currentMessage.value.time = dayjs().toISOString()
|
||||
// 更新消息内容
|
||||
currentMessage.value.content = editingContent.value
|
||||
|
||||
// 发送编辑消息事件
|
||||
emit('change')
|
||||
|
||||
// 关闭对话框
|
||||
handleEditDialogClose()
|
||||
|
||||
ElMessage.success('消息已更新')
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*/
|
||||
const deleteMessage = () => {
|
||||
if (currentMessageIndex.value === -1) return
|
||||
|
||||
// 从消息数组中删除当前消息
|
||||
props.messages.splice(currentMessageIndex.value, 1)
|
||||
|
||||
// 发送删除消息事件
|
||||
emit('change')
|
||||
|
||||
// 隐藏菜单
|
||||
hideContextMenu()
|
||||
|
||||
ElMessage.success('消息已删除')
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用浏览器API朗读消息
|
||||
*/
|
||||
const speakMessage = () => {
|
||||
if (!currentMessage.value) return
|
||||
|
||||
// 获取要朗读的文本
|
||||
const textToSpeak = currentMessage.value.content ||
|
||||
currentMessage.value.reasoning_content ||
|
||||
(currentMessage.value.result || '')
|
||||
|
||||
// 检查浏览器是否支持语音合成
|
||||
if ('speechSynthesis' in window) {
|
||||
// 创建语音合成实例
|
||||
const utterance = new SpeechSynthesisUtterance(textToSpeak)
|
||||
|
||||
// 设置语音属性
|
||||
utterance.lang = 'zh-CN' // 设置语言为中文
|
||||
utterance.rate = 1.0 // 设置语速
|
||||
utterance.pitch = 1.0 // 设置音调
|
||||
|
||||
// 开始朗读
|
||||
window.speechSynthesis.speak(utterance)
|
||||
|
||||
ElMessage.success('正在朗读消息')
|
||||
} else {
|
||||
ElMessage.error('您的浏览器不支持语音合成')
|
||||
}
|
||||
|
||||
// 隐藏菜单
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理问题点击事件
|
||||
* @param {string} text - 问题文本
|
||||
*/
|
||||
const handleQuestionClick = (text) => {
|
||||
emit('item-click', text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Markdown内容并支持代码高亮
|
||||
* @param {string} content - 需要解析的Markdown内容
|
||||
* @returns {string} - 解析后的HTML字符串
|
||||
*/
|
||||
const parseMarkdown = (content) => {
|
||||
if (!content) return ''
|
||||
return marked(content)
|
||||
}
|
||||
|
||||
// 点击页面其他区域时隐藏右键菜单
|
||||
const handleDocumentClick = () => {
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
// 组件挂载时添加全局点击事件监听
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
// 组件卸载前移除全局点击事件监听
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
// 可折叠内容区域通用样式
|
||||
.collapsible_wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapsible_tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid #e0e0e0;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:hover {
|
||||
background-color: #e8e8e8;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
.collapsible-icon {
|
||||
margin-right: 6px;
|
||||
transition: transform 0.3s;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
|
||||
&.is-rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible_content {
|
||||
position: relative;
|
||||
margin: 5px 0;
|
||||
padding: 0px 12px;
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
&:before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: ' ';
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
}
|
||||
|
||||
// 右键菜单样式
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
padding: 5px 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
background-color: #282c34; // Atom One Dark 背景色
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: #3a404b; // Atom One Dark 次要背景色
|
||||
color: #abb2bf; // Atom One Dark 文本颜色
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
:deep(pre code) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 100%;
|
||||
word-break: normal;
|
||||
white-space: pre;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #abb2bf; // Atom One Dark 文本颜色
|
||||
}
|
||||
|
||||
// 添加代码块的滚动条样式
|
||||
:deep(pre)::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
:deep(pre)::-webkit-scrollbar-thumb {
|
||||
background: #3a404b;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(pre)::-webkit-scrollbar-track {
|
||||
background: #282c34;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
160
src/views/knowledge/aiFlow/components/CodeEditor.vue
Normal file
160
src/views/knowledge/aiFlow/components/CodeEditor.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="code-editor">
|
||||
<textarea ref="textarea" v-show="false"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import CodeMirror from 'codemirror';
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
// 主题
|
||||
import 'codemirror/theme/idea.css';
|
||||
import 'codemirror/theme/nord.css';
|
||||
import 'codemirror/theme/xq-light.css';
|
||||
import 'codemirror/mode/clike/clike';
|
||||
import 'codemirror/mode/javascript/javascript';
|
||||
import 'codemirror/addon/display/autorefresh';
|
||||
// 搜索
|
||||
import 'codemirror/addon/scroll/annotatescrollbar.js';
|
||||
import 'codemirror/addon/search/matchesonscrollbar.js';
|
||||
import 'codemirror/addon/search/match-highlighter.js';
|
||||
import 'codemirror/addon/search/jump-to-line.js';
|
||||
import 'codemirror/addon/dialog/dialog.js';
|
||||
import 'codemirror/addon/dialog/dialog.css';
|
||||
import 'codemirror/addon/search/searchcursor.js';
|
||||
import 'codemirror/addon/search/search.js';
|
||||
// 折叠
|
||||
import 'codemirror/addon/fold/foldgutter.css';
|
||||
import 'codemirror/addon/fold/foldcode';
|
||||
import 'codemirror/addon/fold/foldgutter';
|
||||
import 'codemirror/addon/fold/brace-fold';
|
||||
import 'codemirror/addon/fold/comment-fold';
|
||||
// 格式化
|
||||
import formatter from '../utils/formatter';
|
||||
import { validatejson, validatenull } from '../utils/validate';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '450px',
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'javascript',
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'idea',
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
json: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
// 定义DOM引用
|
||||
const textarea = ref<HTMLTextAreaElement | null>(null);
|
||||
// 编辑器实例
|
||||
let editor: any = null;
|
||||
|
||||
// 格式化代码方法
|
||||
const prettyCode = () => {
|
||||
if (props.json && editor) {
|
||||
const val = editor.getValue();
|
||||
if (validatenull(val)) {
|
||||
ElMessage.warning('请先填写数据');
|
||||
return;
|
||||
}
|
||||
if (!validatejson(val)) {
|
||||
ElMessage.warning('数据 JSON 格式错误');
|
||||
return;
|
||||
}
|
||||
editor.setValue(formatter.prettyCode(val));
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载后初始化编辑器
|
||||
onMounted(() => {
|
||||
editor = CodeMirror.fromTextArea(textarea.value, {
|
||||
mode: props.mode,
|
||||
theme: props.theme,
|
||||
readOnly: props.readonly,
|
||||
autoRefresh: true,
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: 2,
|
||||
foldGutter: true,
|
||||
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
||||
extraKeys: {
|
||||
// 绑定快捷键 Ctrl-F / Cmd-F 开启搜索
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-F': 'findPersistent',
|
||||
// 如果需要,您还可以添加 "Ctrl-R"/"Cmd-R" 绑定替换功能
|
||||
'Ctrl-R': 'replace',
|
||||
'Cmd-R': 'replace',
|
||||
},
|
||||
});
|
||||
|
||||
// 设置高度
|
||||
editor.setSize('auto', props.height);
|
||||
|
||||
// 设置文本
|
||||
const editorValue = props.json ? formatter.prettyCode(props.modelValue) : props.modelValue;
|
||||
editor.setValue(editorValue);
|
||||
|
||||
// 监听变化
|
||||
editor.on('change', () => {
|
||||
emit('update:modelValue', editor.getValue());
|
||||
});
|
||||
});
|
||||
|
||||
// 监听属性变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (editor && newVal !== editor.getValue()) {
|
||||
const editorValue = props.json ? formatter.prettyCode(props.modelValue) : props.modelValue;
|
||||
editor.setValue(editorValue);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.height,
|
||||
(newHeight) => {
|
||||
if (editor) {
|
||||
editor.setSize('auto', newHeight);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
prettyCode,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-editor {
|
||||
line-height: 1.2 !important;
|
||||
width: calc(100% - 4px);
|
||||
height: 100%;
|
||||
border: 1px solid #ccc; /* 添加边框样式 */
|
||||
}
|
||||
</style>
|
||||
88
src/views/knowledge/aiFlow/components/HiLight.vue
Normal file
88
src/views/knowledge/aiFlow/components/HiLight.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="code-block-container">
|
||||
<pre v-if="highlightedCode"><code v-html="highlightedCode" class="hljs"></code></pre>
|
||||
<button v-if="clipboard" v-clipboard:copy="code" v-clipboard:success="onCopySuccess" class="copy-btn">一键复制</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import hljs from 'highlight.js';
|
||||
import formatter from '../utils/formatter';
|
||||
// 引入语言支持
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import java from 'highlight.js/lib/languages/java';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
code: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'javascript',
|
||||
},
|
||||
clipboard: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 注册语言
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('java', java);
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
|
||||
// 计算高亮代码
|
||||
const highlightedCode = computed(() => {
|
||||
let code = props.code;
|
||||
if (props.language === 'json') {
|
||||
code = formatter.prettyCode(props.code);
|
||||
}
|
||||
return hljs.highlight(code, { language: props.language, ignoreIllegals: true }).value;
|
||||
});
|
||||
|
||||
// 复制成功回调
|
||||
const onCopySuccess = () => {
|
||||
ElMessage.success('复制成功');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-block-container {
|
||||
position: relative;
|
||||
background-color: #f0f0f0;
|
||||
width: 100%;
|
||||
overflow-y: auto; /* 只显示纵向滚动条 */
|
||||
overflow-x: hidden; /* 隐藏横向滚动条 */
|
||||
white-space: pre-wrap; /* 防止内容溢出 */
|
||||
word-wrap: break-word; /* 自动换行 */
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0.5em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: 0.75em;
|
||||
color: #fff;
|
||||
background-color: #606266;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background-color: #909399;
|
||||
}
|
||||
</style>
|
||||
72
src/views/knowledge/aiFlow/components/LineChart.vue
Normal file
72
src/views/knowledge/aiFlow/components/LineChart.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="line-chart">
|
||||
<svg :width="width" :height="height">
|
||||
<!-- 绘制折线 -->
|
||||
<path :d="pathD" fill="none" stroke="#67c23a" stroke-width="2" />
|
||||
<!-- 绘制点 -->
|
||||
<circle v-for="point in points" :key="point.x" :cx="point.x" :cy="point.y" r="4" fill="#67c23a" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
// 定义数据点接口
|
||||
interface DataPoint {
|
||||
value: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 定义坐标点接口
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array as () => DataPoint[],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 图表尺寸
|
||||
const width = 500;
|
||||
const height = 100;
|
||||
const padding = 20;
|
||||
|
||||
// 计算坐标点
|
||||
const points = computed(() => {
|
||||
if (!props.data.length) return [];
|
||||
|
||||
const xStep = (width - padding * 2) / (props.data.length - 1);
|
||||
const maxValue = Math.max(...props.data.map((d) => d.value));
|
||||
const yScale = maxValue ? (height - padding * 2) / maxValue : 1;
|
||||
|
||||
return props.data.map((d, i) => ({
|
||||
x: padding + i * xStep,
|
||||
y: height - padding - d.value * yScale,
|
||||
}));
|
||||
});
|
||||
|
||||
// 生成SVG路径
|
||||
const pathD = computed(() => {
|
||||
if (!points.value.length) return '';
|
||||
|
||||
return points.value.reduce((path, point, i) => {
|
||||
return path + (i === 0 ? `M ${point.x},${point.y}` : ` L ${point.x},${point.y}`);
|
||||
}, '');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
87
src/views/knowledge/aiFlow/components/MainContainer.vue
Normal file
87
src/views/knowledge/aiFlow/components/MainContainer.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<!-- 基础容器组件 -->
|
||||
<template>
|
||||
<el-container class="basic-container">
|
||||
<!-- 头部卡片区域 -->
|
||||
<el-card v-if="$slots.header" class="header-card" shadow="never">
|
||||
<div class="card-title" v-if="headerTitle">{{ headerTitle }}</div>
|
||||
<slot name="header"></slot>
|
||||
</el-card>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<el-card class="content-card" shadow="never">
|
||||
<div class="card-title" v-if="title">{{ title }}</div>
|
||||
<div class="content" v-loading="loading">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSlots } from 'vue';
|
||||
|
||||
// 定义组件属性
|
||||
defineProps({
|
||||
// 主标题
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 头部标题
|
||||
headerTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 加载状态
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 获取插槽
|
||||
const slots = useSlots();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-container {
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #f5f7fa;
|
||||
/**padding: 20px;
|
||||
gap: 20px;**/
|
||||
box-sizing: border-box;
|
||||
|
||||
.header-card {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-card) {
|
||||
border: none;
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: var(--el-border-color-lighter);
|
||||
|
||||
.el-table__cell {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
src/views/knowledge/aiFlow/components/ModelSelect.vue
Normal file
107
src/views/knowledge/aiFlow/components/ModelSelect.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<el-select v-model="selectedModel" class="w-full model-select" :disabled="disabled" @change="handleChange">
|
||||
<template #label>
|
||||
<div style="display: flex; align-items: center">
|
||||
<img v-if="currentModelIcon" :src="currentModelIcon" class="icon-img" :alt="String(selectedModel)" />
|
||||
<span>{{ selectedModel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-option v-for="item in modelList" :key="item.value" :label="item.label" :value="item.label">
|
||||
<template #default>
|
||||
<div style="display: flex; align-items: center">
|
||||
<svg-icon :size="24" class="param-icon" name="local-llm" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, PropType } from 'vue';
|
||||
import { list } from '/@/api/knowledge/aiModel';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
// 定义模型接口
|
||||
interface Model {
|
||||
id: number;
|
||||
modelName: string;
|
||||
icon: string;
|
||||
label?: string;
|
||||
value?: string | number;
|
||||
}
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => ['Chat', 'Reason'],
|
||||
},
|
||||
// v-model绑定值
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
// 模型列表数据
|
||||
const modelList = ref<Model[]>([]);
|
||||
|
||||
// 计算属性:用于双向绑定
|
||||
const selectedModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
// 计算当前选中模型的图标
|
||||
const currentModelIcon = computed(() => {
|
||||
const currentModel = modelList.value.find((item) => item.label === selectedModel.value);
|
||||
return currentModel ? currentModel.icon : '';
|
||||
});
|
||||
|
||||
// 获取模型列表数据
|
||||
const getModelListData = async () => {
|
||||
try {
|
||||
// 调用后台API获取模型列表
|
||||
const response = await list({ modelType: props.type });
|
||||
if (response && response.data) {
|
||||
// 处理数据,添加label和value字段
|
||||
modelList.value = response.data.map((item: any) => ({
|
||||
...item,
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取模型列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理模型变更
|
||||
const handleChange = (val: string | number) => {
|
||||
emit('update:modelValue', val);
|
||||
};
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
getModelListData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.model-select {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.icon-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
173
src/views/knowledge/aiFlow/components/NodeList.vue
Normal file
173
src/views/knowledge/aiFlow/components/NodeList.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<!-- NodeList.vue -->
|
||||
<template>
|
||||
<div class="execution-progress">
|
||||
<div v-for="node in nodes" :key="node.id" @click.stop="node.expanded = !node.expanded">
|
||||
<div class="node-execution" :class="getNodeStatus(node)">
|
||||
<div class="node-info">
|
||||
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + node.type]" :name="`local-${node.type}`" />
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
<div class="node-time">
|
||||
<span>{{ formatDuration(node.duration) }}</span>
|
||||
<span v-if="node.tokens"> · {{ node.tokens }} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 执行状态图标 -->
|
||||
<div class="status-icon">
|
||||
<el-icon :class="getStatusIconClass(node)">
|
||||
<component :is="getStatusIcon(node)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trace-info" v-if="node.expanded">
|
||||
<!-- 添加输入输出展示 -->
|
||||
<div class="io-container">
|
||||
<!-- 输入数据 -->
|
||||
<div class="io-section" v-if="node.input">
|
||||
<div class="io-header" @click.stop="toggleIO(node.id, 'input')">
|
||||
<el-icon :class="{ 'is-rotate': isIOExpanded(node.id, 'input') }">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
<span>输入</span>
|
||||
</div>
|
||||
<div class="io-content" v-show="isIOExpanded(node.id, 'input')">
|
||||
<pre>{{ formatJSON(node.input) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出数据 -->
|
||||
<div class="io-section">
|
||||
<div class="io-header" @click.stop="toggleIO(node.id, 'output')">
|
||||
<el-icon :class="{ 'is-rotate': isIOExpanded(node.id, 'output') }">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
<span>输出</span>
|
||||
</div>
|
||||
<div class="io-content" v-show="isIOExpanded(node.id, 'output')">
|
||||
<pre>{{ node.error || formatJSON(node.output) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Close, Loading, Check, CircleClose, ArrowRight } from '@element-plus/icons-vue';
|
||||
|
||||
// 定义节点接口
|
||||
interface Node {
|
||||
id: string | number;
|
||||
name: string;
|
||||
type: string;
|
||||
status?: 'running' | 'success' | 'error' | 'skipped' | 'pending';
|
||||
duration?: number;
|
||||
tokens?: number;
|
||||
error?: string;
|
||||
input?: any;
|
||||
output?: any;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
// 定义IO展开状态接口
|
||||
interface ExpandedIO {
|
||||
[key: string]: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 节点数据列表
|
||||
nodes: {
|
||||
type: Array as () => Node[],
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['end']);
|
||||
|
||||
// 存储输入输出展开状态
|
||||
const expandedIO = ref<ExpandedIO>({});
|
||||
|
||||
// 监听节点变化
|
||||
watch(
|
||||
() => props.nodes,
|
||||
(newNodes) => {
|
||||
const lastNode = newNodes[newNodes.length - 1];
|
||||
if (lastNode && (lastNode.status === 'success' || lastNode.status === 'error')) {
|
||||
emit('end', lastNode.status);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 获取节点状态样式
|
||||
const getNodeStatus = (node: Node) => {
|
||||
return {
|
||||
running: node.status === 'running',
|
||||
success: node.status === 'success',
|
||||
error: node.status === 'error',
|
||||
skipped: node.status === 'skipped',
|
||||
pending: !node.status,
|
||||
};
|
||||
};
|
||||
|
||||
// 格式化持续时间
|
||||
const formatDuration = (duration?: number) => {
|
||||
if (!duration) return '0ms';
|
||||
if (duration < 1000) return `${duration}ms`;
|
||||
return `${(duration / 1000).toFixed(3)}s`;
|
||||
};
|
||||
|
||||
// 检查输入输出是否展开
|
||||
const isIOExpanded = (nodeId: string | number, type: string) => {
|
||||
if (!expandedIO.value[nodeId]) {
|
||||
return true;
|
||||
}
|
||||
return expandedIO.value[nodeId][type] !== false;
|
||||
};
|
||||
|
||||
// 切换输入输出的展开状态
|
||||
const toggleIO = (nodeId: string | number, type: string) => {
|
||||
if (!expandedIO.value[nodeId]) {
|
||||
expandedIO.value[nodeId] = {};
|
||||
}
|
||||
const currentState = expandedIO.value[nodeId][type] !== false;
|
||||
expandedIO.value[nodeId][type] = !currentState;
|
||||
};
|
||||
|
||||
// 格式化 JSON 数据
|
||||
const formatJSON = (data: any) => {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch (e) {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标组件
|
||||
const getStatusIcon = (node: Node) => {
|
||||
const iconMap = {
|
||||
running: Loading,
|
||||
success: Check,
|
||||
error: CircleClose,
|
||||
};
|
||||
return iconMap[node.status as keyof typeof iconMap] || null;
|
||||
};
|
||||
|
||||
// 获取状态图标的类名
|
||||
const getStatusIconClass = (node: Node) => {
|
||||
return {
|
||||
'is-loading': node.status === 'running',
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '../styles/flow.scss';
|
||||
</style>
|
||||
75
src/views/knowledge/aiFlow/components/UnpublishedMask.vue
Normal file
75
src/views/knowledge/aiFlow/components/UnpublishedMask.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<!--
|
||||
* @Description: 全屏遮罩组件,用于提示应用未发布状态
|
||||
* @Author: Claude
|
||||
* @Date: 2024-02-07
|
||||
-->
|
||||
<template>
|
||||
<div v-if="visible" class="unpublished-mask">
|
||||
<div class="mask-content">
|
||||
<el-icon class="warning-icon"><WarningFilled /></el-icon>
|
||||
<h2 class="title">应用未发布</h2>
|
||||
<p class="description">当前应用尚未发布,请先发布应用后再进行操作</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { WarningFilled } from '@element-plus/icons-vue';
|
||||
|
||||
// 定义组件属性
|
||||
defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.unpublished-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.mask-content {
|
||||
background-color: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 48px;
|
||||
color: #e6a23c;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1177
src/views/knowledge/aiFlow/index.vue
Normal file
1177
src/views/knowledge/aiFlow/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
178
src/views/knowledge/aiFlow/manage/form.vue
Normal file
178
src/views/knowledge/aiFlow/manage/form.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<el-dialog :title="dialogTitle" v-model="dialogVisible" :width="650" :close-on-click-modal="false" draggable>
|
||||
<el-form :ref="(ref: any) => formRef = ref" :model="formData" :rules="formRules" label-width="90px" label-position="top" v-loading="loading">
|
||||
<el-form-item label="编排名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入编排名称"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="编排描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
placeholder="描述该编排的应用场景及用途,如:XXX 小助手回答用户提出的 XXX 产品使用问题"
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="dialogType === 'add'" label="选择应用类型" prop="type">
|
||||
<el-radio-group v-model="formData.type" class="flex gap-3 w-full">
|
||||
<el-radio :label="EOrchestrationType.WORK_FLOW" size="large" border class="!h-auto flex-1 m-0">
|
||||
<div class="py-2">
|
||||
<div class="leading-6 text-gray-500 text-md">高级编排</div>
|
||||
<div class="text-sm leading-5 text-gray-400">适合高级用户自定义AI业务流</div>
|
||||
</div>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitHandle" :disabled="loading">
|
||||
<template v-if="dialogType === 'copy'">复制</template>
|
||||
<template v-else>确认</template>
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
IOrchestrationScope,
|
||||
fetchOrchestrationScope,
|
||||
fetchList,
|
||||
orchestrationRemove,
|
||||
IOrchestrationItem,
|
||||
EOrchestrationType,
|
||||
addObj,
|
||||
getObj,
|
||||
putObj,
|
||||
orchestrationExportContent,
|
||||
orchestrationImportContent,
|
||||
} from '/@/api/knowledge/aiFlow';
|
||||
import { cloneDeep, merge, pick } from 'lodash';
|
||||
import { useMessage, useMessageBox } from '/@/hooks/message';
|
||||
import { FormRules } from 'element-plus';
|
||||
import { rule } from '/@/utils/validate';
|
||||
type IItem = IOrchestrationItem & { originId?: string } /* 用于复制时,存储原始id */;
|
||||
interface State {
|
||||
dialogVisible: boolean;
|
||||
loading: boolean;
|
||||
dialogType: 'add' | 'edit' | 'copy';
|
||||
formData: IItem;
|
||||
formRules: FormRules<IOrchestrationItem>;
|
||||
formRef: any;
|
||||
}
|
||||
|
||||
const getDefaultFormData = (obj?: Partial<IItem>): IItem => {
|
||||
return merge(
|
||||
{
|
||||
id: undefined,
|
||||
name: '',
|
||||
description: '',
|
||||
type: EOrchestrationType.WORK_FLOW,
|
||||
},
|
||||
obj || {}
|
||||
);
|
||||
};
|
||||
|
||||
const emit = defineEmits(['refresh']);
|
||||
|
||||
const state = reactive<State>({
|
||||
dialogVisible: false,
|
||||
loading: false,
|
||||
formData: getDefaultFormData(),
|
||||
dialogType: 'add',
|
||||
formRules: {
|
||||
name: [
|
||||
{ required: true, message: '请输入编排名称', trigger: 'blur' },
|
||||
{ validator: rule.overLength, trigger: 'blur' },
|
||||
],
|
||||
description: [{ validator: rule.overLength, trigger: 'blur' }],
|
||||
},
|
||||
formRef: undefined,
|
||||
});
|
||||
|
||||
const { dialogVisible, loading, formData, formRules, formRef, dialogType } = toRefs(state);
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
switch (state.dialogType) {
|
||||
case 'copy':
|
||||
return '复制';
|
||||
case 'edit':
|
||||
return '编辑';
|
||||
default:
|
||||
return '新增';
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @description : 初始化表单
|
||||
* @param {any} id
|
||||
* @return {any}
|
||||
*/
|
||||
async function init(id?: string) {
|
||||
state.loading = true;
|
||||
state.formData = getDefaultFormData({ id });
|
||||
state.formRef.resetFields();
|
||||
try {
|
||||
if (state.dialogType !== 'add' && id) {
|
||||
const res = await getObj(id);
|
||||
if (state.dialogType === 'copy') {
|
||||
state.formData = {
|
||||
...pick(res.data, ['name', 'description']),
|
||||
originId: id,
|
||||
};
|
||||
} else {
|
||||
state.formData = getDefaultFormData(res.data);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
useMessage().error(e.message || '系统异常请联系管理员');
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description : 提交表单
|
||||
* @return {any}
|
||||
*/
|
||||
async function submitHandle() {
|
||||
const valid = await state.formRef.validate().catch(() => {});
|
||||
if (!valid) return false;
|
||||
state.loading = true;
|
||||
const form = cloneDeep(state.formData);
|
||||
try {
|
||||
if (state.dialogType === 'add') {
|
||||
await addObj(form);
|
||||
} else if (state.dialogType === 'copy') {
|
||||
await copy(form.originId as string, pick(form, ['name', 'description']));
|
||||
} else {
|
||||
await putObj(form);
|
||||
}
|
||||
useMessage().success(dialogTitle.value + '成功');
|
||||
state.dialogVisible = false;
|
||||
emit('refresh');
|
||||
} catch (e: any) {
|
||||
useMessage().error(e.message || '系统异常请联系管理员');
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/**
|
||||
* @description : 打开 dialog 窗口,并初始化窗口
|
||||
* @param {string} id
|
||||
* @param {'add' | 'edit' | 'copy'} type 默认 add
|
||||
* @return {any}
|
||||
*/
|
||||
openDialog: async (id?: string, type: 'add' | 'edit' | 'copy' = 'add') => {
|
||||
if (!['add', 'edit', 'copy'].includes(type)) {
|
||||
useMessageBox().error('type参数错误');
|
||||
return false;
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
state.dialogType = type;
|
||||
await nextTick();
|
||||
init(id);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="scss"></style>
|
||||
337
src/views/knowledge/aiFlow/manage/index.vue
Normal file
337
src/views/knowledge/aiFlow/manage/index.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<el-row v-if="showSearch && tableState.queryForm">
|
||||
<el-form :model="tableState.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
|
||||
<el-form-item label="业务名称" prop="name">
|
||||
<el-input v-model="tableState.queryForm.name" placeholder="请输入名称搜索" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button icon="search" type="primary" @click="getDataList"> 查询</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-row>
|
||||
|
||||
<el-row>
|
||||
<div class="mb8" style="width: 100%">
|
||||
<el-button icon="folder-add" type="primary" class="ml10" @click="formRef.openDialog()"> 新 增 </el-button>
|
||||
<el-button icon="upload" type="default" class="ml10" @click="uploadDialogVisible = true"> 导 入 </el-button>
|
||||
<right-toolbar
|
||||
v-model:showSearch="showSearch"
|
||||
:export="false"
|
||||
class="ml10 mr20"
|
||||
style="float: right"
|
||||
@exportExcel="exportExcel"
|
||||
@queryTable="getDataList"
|
||||
></right-toolbar>
|
||||
</div>
|
||||
</el-row>
|
||||
|
||||
<el-scrollbar class="h-[calc(100vh-280px)] mb-4">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="dataset in tableState.dataList"
|
||||
:key="dataset.id"
|
||||
class="group overflow-hidden bg-white rounded-lg shadow-sm border border-gray-100 transition-all duration-300 cursor-pointer dark:bg-gray-800 dark:border-gray-700 hover:shadow-lg hover:border-primary-100 hover:translate-y-[-2px]"
|
||||
@click="openSettingDialog(dataset)"
|
||||
>
|
||||
<div class="p-5">
|
||||
<div class="flex items-start">
|
||||
<div
|
||||
class="flex items-center justify-center text-lg font-medium text-white transition-transform rounded-lg size-12 group-hover:scale-110"
|
||||
:class="dataset.type === EOrchestrationType.WORK_FLOW ? 'bg-amber-500' : 'bg-indigo-600'"
|
||||
>
|
||||
{{ dataset.name ? dataset.name.substring(0, 1).toUpperCase() : '' }}
|
||||
</div>
|
||||
<div class="flex-1 ml-3 overflow-hidden">
|
||||
<div class="text-base font-medium text-gray-900 truncate dark:text-white">
|
||||
{{ dataset.name }}
|
||||
</div>
|
||||
<div class="flex items-center mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<el-icon class="mr-1"><User /></el-icon>
|
||||
{{ dataset.createBy }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-tag type="primary" size="small" class="ml-2" effect="light"> 高级编排 </el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-16 mt-4 overflow-y-auto text-sm text-gray-600 dark:text-gray-300 line-clamp-3">
|
||||
{{ dataset.description || '暂无描述' }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start pt-3 mt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<el-button
|
||||
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
text
|
||||
type="primary"
|
||||
@click.stop="router.push(`/aiFlow/process/${dataset.id}`)"
|
||||
>
|
||||
<el-icon><DataLine /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<div class="w-px h-4 mx-2 bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
|
||||
<el-button
|
||||
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
text
|
||||
type="primary"
|
||||
@click.stop="formRef.openDialog(`${dataset.id}`, 'edit')"
|
||||
>
|
||||
<el-icon><Setting /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<div class="w-px h-4 mx-2 bg-gray-200 dark:bg-gray-700" v-if="dataset.enabled === '1'"></div>
|
||||
|
||||
<el-button
|
||||
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
text
|
||||
type="primary"
|
||||
v-if="dataset.enabled === '1'"
|
||||
@click.stop="router.push(`/knowledge/aiChat/index?datasetId=-9&flowId=${dataset.id}`)"
|
||||
>
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<div class="w-px h-4 mx-2 bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
<el-dropdown trigger="click" @command="(command: string) => itemDropdownHandle(dataset, command)">
|
||||
<el-button
|
||||
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
text
|
||||
type="primary"
|
||||
@click.stop
|
||||
>
|
||||
<el-icon><More /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="copy">复制</el-dropdown-item>
|
||||
<el-dropdown-item command="export">导出</el-dropdown-item>
|
||||
<el-dropdown-item command="remove">删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<el-icon class="mr-1"><Clock /></el-icon>
|
||||
{{ parseDate(dataset.createTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 无数据显示 -->
|
||||
<el-empty v-if="!tableState.dataList || tableState.dataList.length === 0" description="暂无数据"></el-empty>
|
||||
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="tableState.pagination" />
|
||||
</div>
|
||||
<Form ref="formRef" @refresh="getDataList" />
|
||||
|
||||
<!-- 上传对话框 -->
|
||||
<el-dialog v-model="uploadDialogVisible" title="导入文件" width="500px" destroy-on-close>
|
||||
<upload-file
|
||||
ref="elUploadRef"
|
||||
:file-list="[]"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:file-type="['dsl']"
|
||||
uploadFileUrl="/knowledge/aiFlow/import"
|
||||
@change="importHandle"
|
||||
/>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="uploadDialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="submitUpload">确 定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { DataLine, More, Setting, User, ChatDotRound } from '@element-plus/icons-vue';
|
||||
import {
|
||||
IOrchestrationScope,
|
||||
delObjs,
|
||||
fetchList,
|
||||
IOrchestrationItem,
|
||||
EOrchestrationType,
|
||||
exportFlow,
|
||||
copyFlow,
|
||||
importFlow,
|
||||
} from '/@/api/knowledge/aiFlow';
|
||||
import { useMessage, useMessageBox } from '/@/hooks/message';
|
||||
import { BasicTableProps, useTable } from '/@/hooks/table';
|
||||
import Form from './form.vue';
|
||||
import { ElNotification } from 'element-plus';
|
||||
|
||||
interface State {
|
||||
showSearch: boolean;
|
||||
scopeList: IOrchestrationScope[];
|
||||
queryForm: {
|
||||
selectUserId?: string;
|
||||
name?: string;
|
||||
};
|
||||
formRef?: any;
|
||||
elUploadRef?: any;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const queryRef = ref();
|
||||
const uploadDialogVisible = ref(false);
|
||||
const elUploadRef = ref();
|
||||
|
||||
const state = reactive<State>({
|
||||
showSearch: true,
|
||||
scopeList: [],
|
||||
queryForm: {
|
||||
selectUserId: undefined,
|
||||
name: undefined,
|
||||
},
|
||||
formRef: undefined,
|
||||
});
|
||||
|
||||
const tableState = reactive<BasicTableProps>({
|
||||
queryForm: {},
|
||||
pageList: fetchList,
|
||||
dataList: [],
|
||||
pagination: {},
|
||||
});
|
||||
|
||||
const { showSearch, formRef } = toRefs(state);
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle /* , sortChangeHandle, downBlobFile, tableStyle */ } = useTable(tableState);
|
||||
|
||||
/**
|
||||
* @description : 导出
|
||||
* @return {any}
|
||||
*/
|
||||
function exportExcel() {}
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
// 清空搜索条件
|
||||
queryRef.value?.resetFields();
|
||||
tableState.queryForm = {};
|
||||
getDataList();
|
||||
};
|
||||
|
||||
function openSettingDialog(dataset: IOrchestrationItem) {
|
||||
router.push({
|
||||
path: `/aiFlow/process/${dataset.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function itemDropdownHandle(dataset: IOrchestrationItem, command: string) {
|
||||
switch (command) {
|
||||
case 'copy':
|
||||
copyHandle(dataset.id as string);
|
||||
break;
|
||||
case 'remove':
|
||||
deleteHandle(dataset.id as string);
|
||||
break;
|
||||
case 'export':
|
||||
exportHandle(dataset);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description : 导出 方法
|
||||
* @param {any} dataset
|
||||
* @return {any}
|
||||
*/
|
||||
async function exportHandle(dataset: IOrchestrationItem) {
|
||||
try {
|
||||
if (!dataset.id || !dataset.name) {
|
||||
throw new Error('编排ID或名称不能为空');
|
||||
}
|
||||
await exportFlow(dataset.id, dataset.name);
|
||||
} catch (e: any) {
|
||||
useMessage().error(e.msg || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyHandle(id: string) {
|
||||
try {
|
||||
await copyFlow(id);
|
||||
getDataList();
|
||||
useMessage().success('复制成功');
|
||||
} catch (e: any) {
|
||||
useMessage().error(e.msg || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description : 删除方法。
|
||||
* @param {string | string[]} idOrIds id 或 id[]
|
||||
* @return {any}
|
||||
*/
|
||||
async function deleteHandle(idOrIds: string | string[]) {
|
||||
try {
|
||||
await useMessageBox().confirm('此操作将永久删除');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await delObjs([idOrIds]);
|
||||
getDataList();
|
||||
useMessage().success('删除成功');
|
||||
} catch (err: any) {
|
||||
useMessage().error(err.msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description : 导入
|
||||
* @param {any} file
|
||||
* @return {any}
|
||||
*/
|
||||
async function importHandle() {
|
||||
try {
|
||||
useMessage().success('导入成功');
|
||||
getDataList();
|
||||
uploadDialogVisible.value = false;
|
||||
} catch (e: any) {
|
||||
useMessage().error(e.msg || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交上传
|
||||
*/
|
||||
function submitUpload() {
|
||||
if (elUploadRef.value) {
|
||||
elUploadRef.value.submit();
|
||||
}
|
||||
uploadDialogVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-scrollbar__wrap) {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.bg-primary-100 {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
:deep(.el-button.is-text) {
|
||||
padding: 0;
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
497
src/views/knowledge/aiFlow/mixins/Node.ts
Normal file
497
src/views/knowledge/aiFlow/mixins/Node.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
import { Node, Connection, ParamItem, ExecutionNode, ExecutionContext, NodeData } from '../types/node';
|
||||
import { executeFlow, executeFlowSSEWithChat, FlowExecutionCallbacks, FlowExecutionEvent, FlowExecutionResult } from '/@/api/knowledge/aiFlow';
|
||||
import { generateUUID } from '/@/utils/other';
|
||||
|
||||
// Define the mixin as a Vue component options object
|
||||
export default {
|
||||
data(): NodeData {
|
||||
return {
|
||||
conversationId: '',
|
||||
isRunning: false,
|
||||
id: null,
|
||||
form: {},
|
||||
env: [],
|
||||
nodes: [],
|
||||
connections: [],
|
||||
isStream: true,
|
||||
};
|
||||
},
|
||||
async created(this: any) {
|
||||
// 从路由参数中获取id
|
||||
this.id = this.$route.params.id;
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* 计算工作流的执行顺序
|
||||
* @returns {Node[]} 按执行顺序排列的节点数组
|
||||
*/
|
||||
workflowExecutionOrder(this: any): Node[] {
|
||||
// 初始化访问记录和执行顺序数组
|
||||
const visited = new Set<string>();
|
||||
const executionOrder: Node[] = [];
|
||||
|
||||
// 解构获取节点和连接信息
|
||||
const { nodes, connections } = this;
|
||||
|
||||
// 查找开始节点
|
||||
const startNode = nodes.find((node: Node) => node.type === 'start');
|
||||
if (!startNode) {
|
||||
// 使用this.$log替代console以避免linter错误
|
||||
this.$log?.warn?.('未找到开始节点');
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度优先搜索遍历节点
|
||||
* @param {string} nodeId - 当前节点ID
|
||||
* @param {string[]} path - 当前路径
|
||||
* @param {number|null} branchIndex - 分支索引
|
||||
*/
|
||||
const dfs = (nodeId: string, path: string[] = [], branchIndex: number | null = null): void => {
|
||||
// 检查循环依赖
|
||||
if (visited.has(nodeId)) {
|
||||
if (path.includes(nodeId)) {
|
||||
const cycleNodes: string[] = path.slice(path.indexOf(nodeId));
|
||||
// 使用this.$log替代console以避免linter错误
|
||||
this.$log?.warn?.('检测到循环依赖,涉及节点:', cycleNodes);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前节点
|
||||
const currentNode = nodes.find((node: Node) => node.id === nodeId);
|
||||
if (!currentNode) {
|
||||
// 使用this.$log替代console以避免linter错误
|
||||
this.$log?.warn?.('未找到节点:', nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记节点为已访问
|
||||
visited.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
// 设置分支信息
|
||||
if (branchIndex !== null) {
|
||||
currentNode._branchIndex = branchIndex;
|
||||
}
|
||||
|
||||
// 添加到执行顺序
|
||||
executionOrder.push(currentNode);
|
||||
|
||||
// 获取所有出边并按端口索引排序
|
||||
const outgoingConnections = connections
|
||||
.filter((conn: Connection) => conn.sourceId === nodeId)
|
||||
.sort((a: Connection, b: Connection) => (a.portIndex || 0) - (b.portIndex || 0));
|
||||
|
||||
// 根据节点类型处理后续节点
|
||||
if (['switch', 'question'].includes(currentNode.type)) {
|
||||
// 分支节点:为每个分支的节点添加分支索引
|
||||
outgoingConnections.forEach((conn: Connection) => {
|
||||
dfs(conn.targetId, [...path], conn.portIndex || 0);
|
||||
});
|
||||
} else {
|
||||
// 普通节点:继承当前分支索引
|
||||
outgoingConnections.forEach((conn: Connection) => {
|
||||
dfs(conn.targetId, [...path], branchIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 从开始节点开始遍历
|
||||
dfs(startNode.id);
|
||||
|
||||
return executionOrder;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 深拷贝对象
|
||||
* @param {T} obj - 要拷贝的对象
|
||||
* @returns {T} 拷贝后的对象
|
||||
*/
|
||||
deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化全局环境变量
|
||||
*/
|
||||
initGlobalEnv(this: any): void {
|
||||
// 确保 window.$glob 存在
|
||||
if (!window.$glob) window.$glob = {};
|
||||
|
||||
// 确保 this.env 是一个数组
|
||||
if (!this.env || !Array.isArray(this.env)) {
|
||||
this.env = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.env.forEach((item: { name: string; value: any }) => {
|
||||
if (item.name) window.$glob[item.name] = item.value;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 重置会话,在关闭面板时调用
|
||||
*/
|
||||
resetConversation(this: any): void {
|
||||
this.conversationId = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理工具栏运行按钮点击
|
||||
*/
|
||||
handleRunClick(this: any): void {
|
||||
// 如果没有会话ID,则创建一个
|
||||
if (!this.conversationId) {
|
||||
this.conversationId = generateUUID();
|
||||
}
|
||||
// 获取开始节点
|
||||
const startNode = this.nodes.find((node: Node) => node.type === 'start');
|
||||
if (startNode?.outputParams?.length) {
|
||||
this.startNodeParams = this.deepClone(startNode.inputParams);
|
||||
this.showExecutionPanel = true;
|
||||
} else {
|
||||
// 如果没有参数,直接运行
|
||||
this.runWorkflow();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 使用SSE运行工作流(实时状态更新版本)
|
||||
* @param {ParamItem[] | null} params - 开始节点参数
|
||||
* @returns {Promise<ExecutionContext>} 执行上下文
|
||||
*/
|
||||
async runWorkflowSSE(this: any, params: ParamItem[] | null = null): Promise<ExecutionContext> {
|
||||
this.isRunning = true;
|
||||
this.initGlobalEnv();
|
||||
this.showExecutionPanel = true;
|
||||
const workflowExecutionOrder = this.deepClone(this.workflowExecutionOrder);
|
||||
this.executionNodes = [];
|
||||
|
||||
const workflowStartTime = performance.now();
|
||||
|
||||
try {
|
||||
const executionOrder = workflowExecutionOrder;
|
||||
const firstNode = executionOrder[0];
|
||||
firstNode.inputParams = [];
|
||||
const context: ExecutionContext = {
|
||||
variables: {},
|
||||
params: {},
|
||||
envs: {},
|
||||
};
|
||||
|
||||
// 如果存在环境变量,也添加到开始节点的输入参数中
|
||||
if (this.env && Array.isArray(this.env) && this.env.length > 0) {
|
||||
this.env.forEach((item: { name: string; value: any }) => {
|
||||
if (!item.name) return;
|
||||
const envKey = `global.${item.name}`;
|
||||
context.envs[envKey] = item.value;
|
||||
firstNode.inputParams!.push({ type: envKey });
|
||||
});
|
||||
}
|
||||
|
||||
// 如果有开始节点参数,设置到上下文中
|
||||
if (params) {
|
||||
params.forEach((param) => {
|
||||
const key = `${firstNode.id}.${param.key || param.type}`;
|
||||
context.params[key] = param.value;
|
||||
firstNode.inputParams!.push({ type: key });
|
||||
});
|
||||
}
|
||||
|
||||
// 根据isStream属性决定使用SSE还是普通请求
|
||||
if (this.isStream) {
|
||||
// 使用SSE聊天执行工作流
|
||||
await this.executeWithSSE(context);
|
||||
} else {
|
||||
// 使用普通请求
|
||||
await this.executeWithHTTP(context);
|
||||
}
|
||||
|
||||
return context;
|
||||
} catch (error: any) {
|
||||
this.$log?.error?.('运行工作流失败:', error);
|
||||
this.executionResult = { error: error.message };
|
||||
|
||||
const workflowEndTime = performance.now();
|
||||
const totalDuration = workflowEndTime - workflowStartTime;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.executionTime = totalDuration.toFixed(3);
|
||||
this.isRunning = false;
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 使用SSE方式执行工作流
|
||||
* @param {ExecutionContext} context - 执行上下文
|
||||
*/
|
||||
async executeWithSSE(this: any, context: ExecutionContext): Promise<void> {
|
||||
// 初始化聊天消息累积器
|
||||
let chatMessageContent = '';
|
||||
let isChatStreaming = false;
|
||||
|
||||
// 设置SSE回调函数
|
||||
const callbacks: FlowExecutionCallbacks = {
|
||||
onStart: () => {
|
||||
this.$log?.info?.('工作流开始执行');
|
||||
// 重置聊天消息
|
||||
chatMessageContent = '';
|
||||
isChatStreaming = false;
|
||||
},
|
||||
onProgress: (event: FlowExecutionEvent) => {
|
||||
this.$log?.info?.(`节点执行进度: ${event.nodeName} (${event.nodeId})`);
|
||||
|
||||
// 更新执行节点状态
|
||||
if (event.nodeId && this.executionNodes) {
|
||||
const nodeIndex = this.executionNodes.findIndex((n: ExecutionNode) => n.id === event.nodeId);
|
||||
if (nodeIndex >= 0) {
|
||||
this.$nextTick(() => {
|
||||
this.executionNodes[nodeIndex] = {
|
||||
...this.executionNodes[nodeIndex],
|
||||
status: 'running',
|
||||
...event.data
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 可以在这里添加进度条或其他UI更新
|
||||
if (event.progress !== undefined) {
|
||||
this.$log?.info?.(`执行进度: ${event.progress}%`);
|
||||
}
|
||||
},
|
||||
onChatMessage: (content: string, isComplete: boolean, tokens?: number, duration?: number, nodes?: Array<any>) => {
|
||||
// 累积聊天消息内容
|
||||
chatMessageContent += content;
|
||||
isChatStreaming = true;
|
||||
|
||||
// 处理 tokens、duration 和 nodes 信息
|
||||
if (isComplete) {
|
||||
if (tokens) {
|
||||
this.totalTokens = tokens;
|
||||
}
|
||||
if (duration) {
|
||||
this.executionTime = duration;
|
||||
}
|
||||
if (nodes) {
|
||||
this.executionNodes = nodes;
|
||||
}
|
||||
}
|
||||
|
||||
// 实时更新executionResult以显示聊天消息
|
||||
this.$nextTick(() => {
|
||||
this.executionResult = {
|
||||
...this.executionResult,
|
||||
chatMessage: chatMessageContent,
|
||||
isStreaming: !isComplete
|
||||
};
|
||||
});
|
||||
},
|
||||
onComplete: (result: FlowExecutionResult) => {
|
||||
this.$log?.info?.('工作流执行完成');
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.executionNodes = result.nodes;
|
||||
// 如果有聊天消息,将其合并到结果中
|
||||
if (isChatStreaming && chatMessageContent) {
|
||||
this.executionResult = {
|
||||
...result.result,
|
||||
chatMessage: chatMessageContent,
|
||||
isStreaming: false
|
||||
};
|
||||
} else {
|
||||
this.executionResult = result.result;
|
||||
}
|
||||
this.executionTime = Number(result.duration);
|
||||
this.totalTokens = Number(result.totalTokens || 0);
|
||||
this.isRunning = false;
|
||||
});
|
||||
},
|
||||
onError: (error: string) => {
|
||||
this.$log?.error?.('工作流执行失败:', error);
|
||||
|
||||
this.$nextTick(() => {
|
||||
// 如果有部分聊天消息,也保留在错误结果中
|
||||
const errorResult: any = { error };
|
||||
if (isChatStreaming && chatMessageContent) {
|
||||
errorResult.chatMessage = chatMessageContent;
|
||||
errorResult.isStreaming = false;
|
||||
}
|
||||
this.executionResult = errorResult;
|
||||
this.isRunning = false;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const chatResult = await executeFlowSSEWithChat(
|
||||
{ id: this.id, conversationId: this.conversationId, params: context.params, envs: context.envs, stream: true },
|
||||
callbacks
|
||||
);
|
||||
|
||||
// 将聊天结果写入executionResult
|
||||
this.$nextTick(() => {
|
||||
this.executionResult = {
|
||||
chatMessage: chatResult.chatMessage,
|
||||
result: chatResult.result,
|
||||
isStreaming: false
|
||||
};
|
||||
this.isRunning = false;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 使用普通HTTP请求执行工作流
|
||||
* @param {ExecutionContext} context - 执行上下文
|
||||
*/
|
||||
async executeWithHTTP(this: any, context: ExecutionContext): Promise<void> {
|
||||
const { data } = await executeFlow({
|
||||
id: this.id,
|
||||
params: context.params,
|
||||
envs: context.envs,
|
||||
stream: false
|
||||
});
|
||||
|
||||
// 处理普通JSON响应
|
||||
this.$nextTick(() => {
|
||||
// 检查响应数据结构,支持不同的返回格式
|
||||
if (data.nodes && data.result !== undefined) {
|
||||
// 标准格式: { nodes, result, duration, totalTokens }
|
||||
this.executionNodes = data.nodes;
|
||||
this.executionResult = data.result;
|
||||
this.executionTime = Number(data.duration || 0);
|
||||
this.totalTokens = Number(data.totalTokens || 0);
|
||||
} else if (data.data) {
|
||||
// 嵌套格式: { data: { nodes, result, duration, totalTokens } }
|
||||
const responseData = data.data;
|
||||
this.executionNodes = responseData.nodes || [];
|
||||
this.executionResult = responseData.result || responseData;
|
||||
this.executionTime = Number(responseData.duration || 0);
|
||||
this.totalTokens = Number(responseData.totalTokens || 0);
|
||||
} else {
|
||||
// 直接返回格式:直接使用 data 作为结果
|
||||
this.executionNodes = data.nodes || [];
|
||||
this.executionResult = data;
|
||||
this.executionTime = Number(data.duration || 0);
|
||||
this.totalTokens = Number(data.totalTokens || 0);
|
||||
}
|
||||
|
||||
// 如果结果包含聊天消息内容,也进行处理
|
||||
if (this.executionResult && typeof this.executionResult === 'object') {
|
||||
// 检查是否有聊天消息相关字段
|
||||
if (this.executionResult.chatMessage || this.executionResult.content) {
|
||||
const chatMessage = this.executionResult.chatMessage || this.executionResult.content;
|
||||
this.executionResult = {
|
||||
...this.executionResult,
|
||||
chatMessage: chatMessage,
|
||||
isStreaming: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 运行工作流
|
||||
* @param {ParamItem[] | null} params - 开始节点参数
|
||||
* @returns {Promise<ExecutionContext>} 执行上下文
|
||||
*/
|
||||
async runWorkflow(this: any, params: ParamItem[] | null = null): Promise<ExecutionContext> {
|
||||
// 默认使用SSE版本
|
||||
return this.runWorkflowSSE(params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 判断节点是否应该被跳过
|
||||
* @param {Node} node - 当前节点
|
||||
* @param {Map<string, number>} activeBranches - 活动分支信息
|
||||
* @returns {boolean} 是否应该跳过
|
||||
*/
|
||||
shouldSkipNode(this: any, node: Node, activeBranches: Map<string, number>): boolean {
|
||||
// 如果节点没有分支信息,不跳过
|
||||
if (node._branchIndex === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查找当前节点所属的分支节点
|
||||
const branchNodeId = this.findBranchNodeId(node);
|
||||
if (!branchNodeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取活动分支索引
|
||||
const activeBranch = activeBranches.get(branchNodeId);
|
||||
|
||||
// 如果找不到活动分支信息,或者分支索引匹配,则不跳过
|
||||
if (activeBranch === undefined || activeBranch === node._branchIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 查找节点所属的分支节点ID
|
||||
* @param {Node} node - 当前节点
|
||||
* @returns {string|null} 分支节点ID
|
||||
*/
|
||||
findBranchNodeId(this: any, node: Node): string | null {
|
||||
// 遍历所有连接找到源节点
|
||||
const connection = this.connections.find((conn: Connection) => conn.targetId === node.id);
|
||||
if (!connection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceNode = this.nodes.find((n: Node) => n.id === connection.sourceId);
|
||||
if (!sourceNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果源节点是分支节点,返回其ID
|
||||
if (['switch', 'question'].includes(sourceNode.type)) {
|
||||
return sourceNode.id;
|
||||
}
|
||||
|
||||
// 递归查找
|
||||
return this.findBranchNodeId(sourceNode);
|
||||
},
|
||||
|
||||
/**
|
||||
* 解析节点的输入参数
|
||||
* @param {Node} node - 当前节点
|
||||
* @param {ExecutionContext} context - 执行上下文
|
||||
* @returns {Record<string, any>} 解析后的输入参数
|
||||
*/
|
||||
resolveNodeInputParams(this: any, node: Node, context: ExecutionContext): Record<string, any> {
|
||||
const inputParams: Record<string, any> = {
|
||||
id: node.id,
|
||||
};
|
||||
let list = this.deepClone(node.inputParams || []);
|
||||
if (node.type === 'http') {
|
||||
if (node.bodyParams) list = list.concat(node.bodyParams);
|
||||
if (node.headerParams) list = list.concat(node.headerParams);
|
||||
}
|
||||
list?.forEach((ele: ParamItem) => {
|
||||
const { value, name, type } = ele;
|
||||
const key = `${node.id}.${name}`;
|
||||
if (type && type.includes('global')) {
|
||||
inputParams[type] = window.$glob[type.replace('global.', '')];
|
||||
context.params[type] = inputParams[type];
|
||||
} else if (type) {
|
||||
inputParams[type] = context.params[type];
|
||||
} else if (value) {
|
||||
inputParams[key] = value;
|
||||
context.params[key] = value;
|
||||
}
|
||||
});
|
||||
return inputParams;
|
||||
},
|
||||
},
|
||||
};
|
||||
50
src/views/knowledge/aiFlow/nodes/CodeNode.vue
Normal file
50
src/views/knowledge/aiFlow/nodes/CodeNode.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="output-params">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-var" />
|
||||
<span class="param-name">{{ param.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'CodeNode',
|
||||
mixins: [common],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
color: #666;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
55
src/views/knowledge/aiFlow/nodes/DbNode.vue
Normal file
55
src/views/knowledge/aiFlow/nodes/DbNode.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="output-params">
|
||||
<div class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-db" />
|
||||
<span class="param-value">{{ node.dbParams.dbName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'DbNode',
|
||||
mixins: [common],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
padding: 2px 5px;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
50
src/views/knowledge/aiFlow/nodes/EndNode.vue
Normal file
50
src/views/knowledge/aiFlow/nodes/EndNode.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="output-params">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-var" />
|
||||
<span class="param-name">{{ param.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'EndNode',
|
||||
mixins: [common],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
color: #666;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
55
src/views/knowledge/aiFlow/nodes/HttpNode.vue
Normal file
55
src/views/knowledge/aiFlow/nodes/HttpNode.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="output-params"
|
||||
v-if="node.method || node.httpParams.url">
|
||||
<div class="param-item">
|
||||
<span class="param-name">{{ node.httpParams.method }}</span>
|
||||
<span class="param-value">{{ node.httpParams.url }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts'
|
||||
export default {
|
||||
name: 'HttpNode',
|
||||
mixins: [common]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
overflow: hidden;
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
padding: 2px 5px;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
60
src/views/knowledge/aiFlow/nodes/LLMNode.vue
Normal file
60
src/views/knowledge/aiFlow/nodes/LLMNode.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="output-params">
|
||||
<div class="input-params">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-var" />
|
||||
<span class="param-name">{{ param.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-llm" />
|
||||
<span class="param-value">{{ node.llmParams.modelConfig.model }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'LlmNode',
|
||||
mixins: [common],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
padding: 2px 5px;
|
||||
box-sizing: border-box;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
60
src/views/knowledge/aiFlow/nodes/MCPNode.vue
Normal file
60
src/views/knowledge/aiFlow/nodes/MCPNode.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="output-params">
|
||||
<div class="input-params">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-var" />
|
||||
<span class="param-name">{{ param.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-mcp" />
|
||||
<span class="param-value">{{ node.mcpParams.mcpName || node.mcpParams.mcpId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'McpNode',
|
||||
mixins: [common],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
padding: 2px 5px;
|
||||
box-sizing: border-box;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
57
src/views/knowledge/aiFlow/nodes/NoticeNode.vue
Normal file
57
src/views/knowledge/aiFlow/nodes/NoticeNode.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="output-params">
|
||||
<div class="input-params">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-var" />
|
||||
<span class="param-name">{{ param.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'NoticeNode',
|
||||
mixins: [common],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
padding: 2px 5px;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
126
src/views/knowledge/aiFlow/nodes/QuestionNode.vue
Normal file
126
src/views/knowledge/aiFlow/nodes/QuestionNode.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="question-node">
|
||||
<!-- 输入参数展示 -->
|
||||
<div class="input-params">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-var" />
|
||||
<span class="param-name">{{ param.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-llm" />
|
||||
<span class="param-value">{{ node.questionParams.modelConfig.model }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分类列表展示 -->
|
||||
<div class="node-info">
|
||||
<div class="info-item">
|
||||
<div class="categories-list">
|
||||
<div v-for="(item, index) in node.questionParams.categories" :key="index" class="category-item">
|
||||
<div class="category-header">
|
||||
<span class="category-name">{{ `分类${item.name}` || `分类${index + 1}` }}</span>
|
||||
<span class="category-index">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
|
||||
export default {
|
||||
name: 'QuestionNode',
|
||||
mixins: [common],
|
||||
props: {
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.question-node {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.input-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
color: #666;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
padding: 5px 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
|
||||
.info-item {
|
||||
width: 100%;
|
||||
|
||||
.categories-list {
|
||||
width: 100%;
|
||||
|
||||
.category-item {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
box-sizing: border-box;
|
||||
background: #f8fafc;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.category-name {
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.category-index {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
background: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-desc {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
55
src/views/knowledge/aiFlow/nodes/RagNode.vue
Normal file
55
src/views/knowledge/aiFlow/nodes/RagNode.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="output-params">
|
||||
<div class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-db" />
|
||||
<span class="param-value">{{ node.ragParams.datasetName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'RagNode',
|
||||
mixins: [common],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
padding: 2px 5px;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
50
src/views/knowledge/aiFlow/nodes/StartNode.vue
Normal file
50
src/views/knowledge/aiFlow/nodes/StartNode.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="output-params">
|
||||
<div v-for="(param, index) in node.outputParams" :key="index" class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-var" />
|
||||
<span class="param-name">{{ param.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'StartNode',
|
||||
mixins: [common],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
color: #666;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
126
src/views/knowledge/aiFlow/nodes/SwitchNode.vue
Normal file
126
src/views/knowledge/aiFlow/nodes/SwitchNode.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="switch-node">
|
||||
<div class="output-params">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-var" />
|
||||
<span class="param-name">{{ param.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-info">
|
||||
<div class="info-item">
|
||||
<div class="cases-list">
|
||||
<div v-for="(item, index) in node.switchParams.cases" :key="index" class="case-item">
|
||||
<div class="case-header">
|
||||
<span class="case-name">{{ item.name || `分支${index + 1}` }}</span>
|
||||
<span class="case-index">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'SwitchNode',
|
||||
mixins: [common],
|
||||
props: {
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
.param-name {
|
||||
color: #666;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
.switch-node {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
padding: 5px 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
.info-item {
|
||||
width: 100%;
|
||||
.cases-list {
|
||||
width: 100%;
|
||||
|
||||
.case-item {
|
||||
width: 100%;
|
||||
padding: 2px 8px;
|
||||
box-sizing: border-box;
|
||||
background: #f8fafc;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
.case-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.case-name {
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.case-index {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
background: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.case-value {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 2px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
88
src/views/knowledge/aiFlow/nodes/TextNode.vue
Normal file
88
src/views/knowledge/aiFlow/nodes/TextNode.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="output-params">
|
||||
<div class="input-params">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
|
||||
<svg-icon :size="24" class="param-icon" name="local-var" />
|
||||
<span class="param-name">{{ param.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 显示文本内容预览 -->
|
||||
<div v-if="node.textParams && node.textParams.content" class="text-preview">
|
||||
<div class="text-content">
|
||||
{{ getPreviewText(node.textParams.content) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import common from './common.ts';
|
||||
export default {
|
||||
name: 'TextNode',
|
||||
mixins: [common],
|
||||
methods: {
|
||||
// 获取预览文本,限制长度
|
||||
getPreviewText(content) {
|
||||
if (!content) return '未设置文本内容';
|
||||
const maxLength = 50;
|
||||
return content.length > maxLength ? content.substring(0, maxLength) + '...' : content;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加样式 */
|
||||
.output-params {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: rgb(242, 244, 247);
|
||||
}
|
||||
|
||||
.param-icon {
|
||||
color: rgb(41 112 255);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
padding: 2px 5px;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-preview {
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
18
src/views/knowledge/aiFlow/nodes/common.ts
Normal file
18
src/views/knowledge/aiFlow/nodes/common.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Node } from '../types/node';
|
||||
import { PropType } from 'vue';
|
||||
|
||||
export default {
|
||||
inject: ['parent'],
|
||||
props: {
|
||||
node: {
|
||||
type: Object as PropType<Node>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inputParams: this.node.inputParams || [],
|
||||
outputParams: this.node.outputParams || []
|
||||
}
|
||||
}
|
||||
}
|
||||
319
src/views/knowledge/aiFlow/nodes/nodeTypes.ts
Normal file
319
src/views/knowledge/aiFlow/nodes/nodeTypes.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Node } from '../types/node';
|
||||
|
||||
interface NodeType extends Partial<Node> {
|
||||
type: string;
|
||||
name: string;
|
||||
canBeSource: boolean;
|
||||
canBeTarget: boolean;
|
||||
panel?: string;
|
||||
switchParams?: {
|
||||
code: string;
|
||||
cases: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
}>;
|
||||
};
|
||||
questionParams?: {
|
||||
modelConfig: {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
temperature: number;
|
||||
top_p: number;
|
||||
frequency_penalty: number;
|
||||
presence_penalty: number;
|
||||
stream: boolean;
|
||||
};
|
||||
categories: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
};
|
||||
codeParams?: {
|
||||
code: string;
|
||||
};
|
||||
noticeParams?: {
|
||||
message: string;
|
||||
};
|
||||
httpParams?: {
|
||||
url: string;
|
||||
method: string;
|
||||
contentType: string;
|
||||
bodyParams: any[];
|
||||
paramsParams: any[];
|
||||
headerParams: any[];
|
||||
};
|
||||
dbParams?: {
|
||||
sql: string;
|
||||
dbId: string;
|
||||
dbName: string;
|
||||
};
|
||||
llmParams?: {
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
}>;
|
||||
modelConfig: {
|
||||
model: string;
|
||||
isVision: string;
|
||||
max_tokens: number;
|
||||
temperature: number;
|
||||
top_p: number;
|
||||
frequency_penalty: number;
|
||||
presence_penalty: number;
|
||||
stream: boolean;
|
||||
};
|
||||
};
|
||||
mcpParams?: {
|
||||
mcpId: string;
|
||||
mcpName: string;
|
||||
prompt: string;
|
||||
};
|
||||
textParams?: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const nodeTypes: NodeType[] = [
|
||||
{
|
||||
type: 'start',
|
||||
name: '开始',
|
||||
canBeSource: true,
|
||||
canBeTarget: false,
|
||||
panel: 'StartPanel',
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
name: '分支节点',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
switchParams: {
|
||||
code: 'function main(args) {\n // 根据参数,选择执行分支0 ,还是分支1\n return args.arg1 === "xxx" ? 0 : 1;\n}',
|
||||
cases: [
|
||||
{
|
||||
name: '分支1',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: '分支2',
|
||||
value: 1,
|
||||
}
|
||||
],
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'index',
|
||||
type: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'question',
|
||||
name: '问题分类',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
questionParams: {
|
||||
modelConfig: {
|
||||
model: '',
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
stream: true,
|
||||
},
|
||||
categories: [
|
||||
{
|
||||
name: '分类1',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
name: '分类2',
|
||||
value: '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'index',
|
||||
type: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'code',
|
||||
name: '执行代码',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
codeParams: {
|
||||
code: 'function main(args){\n return ""\n}',
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'result',
|
||||
type: 'String',
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'notice',
|
||||
name: '消息通知',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
noticeParams: {
|
||||
message: '',
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'result',
|
||||
type: 'Boolean',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'http',
|
||||
name: 'HTTP请求',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
httpParams: {
|
||||
url: '',
|
||||
method: '',
|
||||
contentType: 'application/json',
|
||||
bodyParams: [],
|
||||
paramsParams: [],
|
||||
headerParams: [],
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Object',
|
||||
},
|
||||
{
|
||||
name: 'status_code',
|
||||
type: 'Number',
|
||||
},
|
||||
{
|
||||
name: 'headers',
|
||||
type: 'Object',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'rag',
|
||||
name: 'RAG知识库',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
ragParams: {
|
||||
datasetId: 0,
|
||||
datasetName: '',
|
||||
prompt: '${arg1}',
|
||||
onlyRecall: '1',
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'result',
|
||||
type: 'String',
|
||||
},
|
||||
{
|
||||
name: 'ragSearched',
|
||||
type: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'db',
|
||||
name: 'DB数据库',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
dbParams: {
|
||||
sql: '',
|
||||
dbId: '',
|
||||
dbName: '',
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'result',
|
||||
type: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'llm',
|
||||
name: 'LLM大模型',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
llmParams: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '你是一个问题总结助手。\n任务:根据用户提问 ${arg1} 和系统答案 ${arg2},生成一个标准的问题答案。\n要求:基于系统答案内容回答,回答准确、简洁、有用。',
|
||||
},
|
||||
],
|
||||
modelConfig: {
|
||||
model: '',
|
||||
isVision: '0',
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'content',
|
||||
type: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'mcp',
|
||||
name: 'MCP服务',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
mcpParams: {
|
||||
mcpId: '',
|
||||
mcpName: '',
|
||||
prompt: '${arg1}',
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'result',
|
||||
type: 'Object',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: '文本节点',
|
||||
canBeSource: true,
|
||||
canBeTarget: true,
|
||||
textParams: {
|
||||
content: '',
|
||||
},
|
||||
outputParams: [
|
||||
{
|
||||
name: 'result',
|
||||
type: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'end',
|
||||
name: '结束',
|
||||
canBeSource: false,
|
||||
canBeTarget: true,
|
||||
},
|
||||
];
|
||||
|
||||
import { deepClone } from '/@/utils/other';
|
||||
|
||||
export const getNodeConfig = (type: string): NodeType => {
|
||||
const nodeConfig = nodeTypes.find((node) => node.type === type) || nodeTypes[0];
|
||||
// 深拷贝节点配置,避免多个节点共享同一个对象引用
|
||||
return deepClone(nodeConfig) as NodeType;
|
||||
};
|
||||
103
src/views/knowledge/aiFlow/panels/CodePanel.vue
Normal file
103
src/views/knowledge/aiFlow/panels/CodePanel.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入参数配置 -->
|
||||
<div class="panel-section mb-2">
|
||||
<div class="panel-header flex justify-between items-center">
|
||||
<span>输入变量</span>
|
||||
<el-button type="primary" size="small" @click="addParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-4">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码编辑区域 -->
|
||||
<div class="panel-section mb-2">
|
||||
<div class="panel-header">
|
||||
<span>代码编辑</span>
|
||||
</div>
|
||||
<code-editor v-model="node.codeParams.code" :json="false" :readonly="false" theme="nord" height="250px" />
|
||||
</div>
|
||||
|
||||
<!-- 输出参数配置 -->
|
||||
<!-- 输出变量 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-header">
|
||||
<span>输出变量</span>
|
||||
</div>
|
||||
<div class="params-list">
|
||||
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="10">
|
||||
<el-text> 变量名: </el-text>
|
||||
<el-tag>{{ output.name }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-text>|</el-text>
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-text> 变量类型: </el-text>
|
||||
<el-tag>{{ output.type }}</el-tag>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import common from './common.ts';
|
||||
import './panel.css';
|
||||
import CodeEditor from '/@/views/knowledge/aiFlow/components/CodeEditor.vue';
|
||||
|
||||
export default {
|
||||
name: 'CodePanel',
|
||||
components: {
|
||||
CodeEditor,
|
||||
Plus,
|
||||
Delete,
|
||||
},
|
||||
mixins: [common],
|
||||
data() {
|
||||
return {
|
||||
code: this.node.code || '',
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 组件特定样式可以在这里添加 */
|
||||
</style>
|
||||
137
src/views/knowledge/aiFlow/panels/DbPanel.vue
Normal file
137
src/views/knowledge/aiFlow/panels/DbPanel.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入变量部分 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>变量输入</span>
|
||||
<el-button @click="addParam" type="primary" size="small">
|
||||
<el-icon><Plus /></el-icon>添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeParam(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据源选择 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>数据源选择</span>
|
||||
</div>
|
||||
<el-select v-model="node.dbParams.dbId" class="w-full" placeholder="请选择数据源" @change="handleDbChange">
|
||||
<el-option value="" label="请选择数据源" />
|
||||
<el-option v-for="db in dbList" :key="db.id" :value="db.id" :label="db.name" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- SQL语句 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>SQL语句</span>
|
||||
</div>
|
||||
<code-editor v-model="node.dbParams.sql" :json="false" :readonly="false" theme="nord" height="250px" />
|
||||
</div>
|
||||
|
||||
<!-- 输出变量 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-header">
|
||||
<span>输出变量</span>
|
||||
</div>
|
||||
<div class="params-list">
|
||||
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-text> 变量名: </el-text>
|
||||
<el-tag>{{ output.name }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-text>|</el-text>
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-text> 变量类型: </el-text>
|
||||
<el-tag>{{ output.type }}</el-tag>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import common from './common.ts';
|
||||
import './panel.css';
|
||||
import {list} from "/@/api/gen/datasource";
|
||||
import CodeEditor from '/@/views/knowledge/aiFlow/components/CodeEditor.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
|
||||
export default {
|
||||
name: 'DbPanel',
|
||||
components: {
|
||||
CodeEditor,
|
||||
Plus,
|
||||
Delete,
|
||||
},
|
||||
mixins: [common],
|
||||
data() {
|
||||
return {
|
||||
dbList: [
|
||||
],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.loadDbList();
|
||||
},
|
||||
methods: {
|
||||
async loadDbList() {
|
||||
const {data} = await list()
|
||||
this.dbList = data
|
||||
},
|
||||
handleDbChange() {
|
||||
if (!this.node.dbParams.dbId) {
|
||||
this.node.dbParams.dbName = '';
|
||||
return;
|
||||
}
|
||||
const selectedDb = this.dbList.find((db) => db.id === this.node.dbParams.dbId);
|
||||
if (selectedDb) {
|
||||
this.node.dbParams.dbName = selectedDb.name;
|
||||
}
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const result = ref(null);
|
||||
return {
|
||||
result
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 组件特定样式可以在这里添加 */
|
||||
</style>
|
||||
61
src/views/knowledge/aiFlow/panels/EndPanel.vue
Normal file
61
src/views/knowledge/aiFlow/panels/EndPanel.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输出变量配置区域 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-header flex justify-between items-center">
|
||||
<span>输出变量</span>
|
||||
<el-button v-if="parent.isFlow" type="primary" size="small" @click="addParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加输出
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 输出变量列表 -->
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" :disabled="!parent.isFlow" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button v-if="parent.isFlow && index > 0" @click="removeParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import common from './common.ts';
|
||||
|
||||
export default {
|
||||
name: 'EndPanel',
|
||||
components: {
|
||||
Plus,
|
||||
Delete,
|
||||
},
|
||||
mixins: [common],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 组件特定样式可以在这里添加 */
|
||||
</style>
|
||||
252
src/views/knowledge/aiFlow/panels/HttpPanel.vue
Normal file
252
src/views/knowledge/aiFlow/panels/HttpPanel.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入变量部分 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>变量输入</span>
|
||||
<el-button type="primary" size="small" @click="addParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeHttpParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请求方法 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>请求方法</span>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6">
|
||||
<el-select v-model="node.httpParams.method" class="w-full">
|
||||
<el-option value="GET" label="GET" />
|
||||
<el-option value="POST" label="POST" />
|
||||
<el-option value="PUT" label="PUT" />
|
||||
<el-option value="DELETE" label="DELETE" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<el-input v-model="node.httpParams.url" placeholder="请求URL" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请求参数 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>请求参数</span>
|
||||
<el-button type="primary" size="small" @click="addHttpParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="Params" name="params">
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in node.httpParams.paramsParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="参数名称" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeHttpParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="Body" name="body" v-if="['POST', 'PUT'].includes(node.httpParams.method)">
|
||||
<el-radio-group v-model="node.httpParams.contentType" size="small" class="mb-2">
|
||||
<el-radio label="none">无</el-radio>
|
||||
<el-radio label="application/x-www-form-urlencoded">Form Data</el-radio>
|
||||
<el-radio label="application/json">JSON</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
<template v-if="node.httpParams.contentType === 'application/x-www-form-urlencoded'">
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in node.httpParams.bodyParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="参数名称" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeBody(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="node.httpParams.contentType === 'application/json'">
|
||||
<el-row>
|
||||
<json-editor v-model="node.httpParams.jsonBody" />
|
||||
</el-row>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="Headers" name="headers">
|
||||
<div class="params-list">
|
||||
<div v-for="(header, index) in node.httpParams.headerParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="header.name" placeholder="Header名称" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="header.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeHeader(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 输出变量 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-header">
|
||||
<span>输出变量</span>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-text> 变量名: </el-text>
|
||||
<el-tag>{{ output.name }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-text>|</el-text>
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-text> 变量类型: </el-text>
|
||||
<el-tag>{{ output.type }}</el-tag>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import common from './common.ts';
|
||||
// @ts-ignore
|
||||
import JsonEditor from '@axolo/json-editor-vue';
|
||||
|
||||
export default {
|
||||
name: 'HttpPanel',
|
||||
components: {
|
||||
JsonEditor,
|
||||
Plus,
|
||||
Delete,
|
||||
},
|
||||
mixins: [common],
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'params', // 默认显示Params标签页
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
addHttpParam() {
|
||||
if (this.activeTab === 'params') {
|
||||
this.node.httpParams.paramsParams.push({});
|
||||
} else if (this.activeTab === 'headers') {
|
||||
this.node.httpParams.headerParams.push({});
|
||||
} else {
|
||||
this.node.httpParams.bodyParams.push({});
|
||||
}
|
||||
},
|
||||
removeHttpParam(index) {
|
||||
this.node.httpParams.paramsParams.splice(index, 1);
|
||||
},
|
||||
addHeader() {
|
||||
this.node.httpParams.headerParams.push({});
|
||||
},
|
||||
removeHeader(index) {
|
||||
this.node.httpParams.headerParams.splice(index, 1);
|
||||
},
|
||||
addBody() {
|
||||
this.node.httpParams.bodyParams.push({});
|
||||
},
|
||||
removeBody(index) {
|
||||
this.node.httpParams.bodyParams.splice(index, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
222
src/views/knowledge/aiFlow/panels/LLMPanel.vue
Normal file
222
src/views/knowledge/aiFlow/panels/LLMPanel.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入变量部分 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>变量输入</span>
|
||||
<el-button type="primary" size="small" @click="addParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表部分 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>对话消息</span>
|
||||
<el-button type="primary" size="small" @click="addMessage">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<el-draggable v-model="messages" :animation="200" item-key="index" handle=".drag-handle" class="w-full">
|
||||
<template #item="{ element: message, index }">
|
||||
<div class="mb-2">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 5px">
|
||||
<el-icon class="cursor-move drag-handle">
|
||||
<Rank />
|
||||
</el-icon>
|
||||
<el-select v-model="message.role" class="w-full">
|
||||
<el-option v-for="option in roleOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
<el-button v-if="index !== 0" @click="removeMessage(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-input v-model="message.content" type="textarea" :rows="3" placeholder="使用${变量名}格式引用上方定义的变量" />
|
||||
</div>
|
||||
</template>
|
||||
</el-draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型参数配置 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>模型配置</span>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<div class="param-item param-item-margin">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">多模态:</span>
|
||||
<el-radio-group v-model="modelConfig.isVision" @change="onVisionChange">
|
||||
<el-radio label="0">否</el-radio>
|
||||
<el-radio label="1">是</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-item param-item-margin">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">大模型:</span>
|
||||
<model-select
|
||||
v-model="modelConfig.model"
|
||||
:key="modelSelectKey"
|
||||
:type="modelConfig.isVision === '1' ? ['Vision'] : undefined"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="modelConfig.isVision === '1'">
|
||||
<div class="param-item param-item-margin">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">图片来源:</span>
|
||||
<el-select v-model="modelConfig.picUrl" placeholder="请选择图片变量" class="flex-1">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 输出变量部分 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-header">
|
||||
<span>输出变量</span>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-text> 变量名: </el-text>
|
||||
<el-tag>{{ output.name }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-text>|</el-text>
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-text> 变量类型: </el-text>
|
||||
<el-tag>{{ output.type }}</el-tag>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete, Rank } from '@element-plus/icons-vue';
|
||||
import common from './common';
|
||||
import draggable from 'vuedraggable';
|
||||
import ModelSelect from '../components/ModelSelect.vue';
|
||||
|
||||
export default {
|
||||
name: 'LlmPanel',
|
||||
mixins: [common],
|
||||
components: {
|
||||
Plus,
|
||||
Delete,
|
||||
Rank,
|
||||
'el-draggable': draggable,
|
||||
ModelSelect,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
messages: this.node.llmParams.messages,
|
||||
modelConfig: this.node.llmParams.modelConfig || {
|
||||
model: '',
|
||||
isVision: '0',
|
||||
max_tokens: 50,
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
},
|
||||
modelList: [],
|
||||
roleOptions: [
|
||||
{ value: 'USER', label: 'USER' },
|
||||
{ value: 'SYSTEM', label: 'SYSTEM' },
|
||||
{ value: 'AI', label: 'ASSISTANT' },
|
||||
],
|
||||
modelSelectKey: 0,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
'modelConfig.picUrl': function (val) {
|
||||
this.node.llmParams.modelConfig.picUrl = val;
|
||||
},
|
||||
modelConfig: {
|
||||
deep: true,
|
||||
handler(val) {
|
||||
this.node.llmParams.modelConfig = val;
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addMessage() {
|
||||
this.messages.push({
|
||||
role: 'USER',
|
||||
content: '',
|
||||
});
|
||||
},
|
||||
removeMessage(index) {
|
||||
if (this.messages.length > 1) {
|
||||
this.messages.splice(index, 1);
|
||||
}
|
||||
},
|
||||
onVisionChange() {
|
||||
this.modelConfig.model = '';
|
||||
this.modelConfig.picUrl = '';
|
||||
this.modelSelectKey++;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cursor-move {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.w-full {
|
||||
margin-right: 15px;
|
||||
}
|
||||
</style>
|
||||
183
src/views/knowledge/aiFlow/panels/MCPPanel.vue
Normal file
183
src/views/knowledge/aiFlow/panels/MCPPanel.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入变量部分 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>变量输入</span>
|
||||
<el-button type="primary" size="small" @click="addParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP 配置部分 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>MCP 配置</span>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<div class="param-item param-item-margin">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">MCP 服务:</span>
|
||||
<el-select v-model="node.mcpParams.mcpId" placeholder="请选择 MCP 服务" class="flex-1" @change="onMcpChange">
|
||||
<el-option v-for="mcp in mcpList" :key="mcp.mcpId" :label="mcp.name" :value="mcp.mcpId">
|
||||
<span style="float: left">{{ mcp.name }}</span>
|
||||
<span style="float: right; color: #8492a6; font-size: 13px">{{ mcp.mcpType }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-item param-item-margin" v-if="node.mcpParams.mcpId">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">请求提示词:</span>
|
||||
<el-input
|
||||
v-model="node.mcpParams.prompt"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请求提示词,使用${变量名}引用上方定义的变量"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 输出变量部分 -->
|
||||
<div class="panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>输出变量</span>
|
||||
<el-button type="primary" size="small" @click="addOutput">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="output.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="output.type" placeholder="变量类型" class="w-full">
|
||||
<el-option label="文本" value="string" />
|
||||
<el-option label="数字" value="number" />
|
||||
<el-option label="布尔值" value="boolean" />
|
||||
<el-option label="对象" value="object" />
|
||||
<el-option label="数组" value="array" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeOutput(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import common from './common';
|
||||
import { list } from '/@/api/knowledge/aiMcpConfig';
|
||||
|
||||
export default {
|
||||
name: 'McpPanel',
|
||||
mixins: [common],
|
||||
components: {
|
||||
Plus,
|
||||
Delete,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mcpList: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectedMcp() {
|
||||
return this.mcpList.find(mcp => mcp.mcpId === this.node.mcpParams?.mcpId);
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
// 确保 mcpParams 对象存在
|
||||
if (!this.node.mcpParams) {
|
||||
this.$set(this.node, 'mcpParams', {
|
||||
mcpId: '',
|
||||
mcpName: '',
|
||||
prompt: '',
|
||||
});
|
||||
}
|
||||
|
||||
await this.fetchMcpList();
|
||||
|
||||
// 确保输出参数有默认值
|
||||
if (!this.outputParams.length) {
|
||||
this.outputParams.push({
|
||||
name: 'result',
|
||||
type: 'string',
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchMcpList() {
|
||||
try {
|
||||
const { data } = await list();
|
||||
this.mcpList = data || [];
|
||||
} catch (error) {
|
||||
console.error('获取 MCP 列表失败:', error);
|
||||
}
|
||||
},
|
||||
onMcpChange() {
|
||||
const selectedMcp = this.selectedMcp;
|
||||
if (selectedMcp) {
|
||||
this.node.mcpParams.mcpName = selectedMcp.name;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.w-full {
|
||||
margin-right: 15px;
|
||||
}
|
||||
</style>
|
||||
165
src/views/knowledge/aiFlow/panels/NoticePanel.vue
Normal file
165
src/views/knowledge/aiFlow/panels/NoticePanel.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入变量部分 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>变量输入</span>
|
||||
<el-button type="primary" size="small" @click="addParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息模板 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>消息模板</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 template-select">
|
||||
<el-select v-model="node.noticeParams.channelId" placeholder="请选择渠道" style="width: 200px" @change="handleChannelChange">
|
||||
<el-option v-for="channel in channelList" :key="channel.value" :value="channel.value" :label="channel.label" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="node.noticeParams.templateId"
|
||||
placeholder="请选择模板"
|
||||
style="width: 400px"
|
||||
:disabled="!node.noticeParams.channelId"
|
||||
@change="handleTemplateChange"
|
||||
>
|
||||
<el-option v-for="template in templateList" :key="template.configKey" :value="template.configKey" :label="template.configName" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>消息内容</span>
|
||||
</div>
|
||||
<code-editor v-model="node.noticeParams.templateCode" :json="false" :readonly="false" theme="nord" height="250px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import common from './common';
|
||||
import './panel.css';
|
||||
import { list } from '/@/api/admin/message';
|
||||
import CodeEditor from '/@/views/knowledge/aiFlow/components/CodeEditor.vue';
|
||||
|
||||
export default {
|
||||
name: 'NoticePanel',
|
||||
components: {
|
||||
Plus,
|
||||
Delete,
|
||||
CodeEditor,
|
||||
},
|
||||
mixins: [common],
|
||||
data() {
|
||||
return {
|
||||
channelList: [
|
||||
{
|
||||
value: 'webhook',
|
||||
label: 'HOOK',
|
||||
},
|
||||
{
|
||||
value: 'sms',
|
||||
label: '短信',
|
||||
},
|
||||
{
|
||||
value: 'email',
|
||||
label: '邮件',
|
||||
},
|
||||
], // 渠道列表
|
||||
templateList: [], // 模板列表
|
||||
};
|
||||
},
|
||||
created() {},
|
||||
methods: {
|
||||
// 渠道变更处理
|
||||
async handleChannelChange() {
|
||||
if (this.node.noticeParams.channelId) {
|
||||
const { data } = await list({ messageType: this.node.noticeParams.channelId });
|
||||
this.templateList = data || [];
|
||||
}
|
||||
},
|
||||
// 模板变更处理
|
||||
handleTemplateChange() {
|
||||
// 存储模板ID和模板代码
|
||||
if (this.currentTemplate) {
|
||||
this.node.noticeParams.templateId = this.currentTemplate.configKey;
|
||||
this.node.noticeParams.templateCode = this.currentTemplate.templateCode;
|
||||
} else {
|
||||
this.node.noticeParams.templateCode = '';
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
// 获取当前选中的模板对象
|
||||
currentTemplate() {
|
||||
return this.templateList.find((template) => template.configKey === this.node.noticeParams.templateId) || {};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.template-select {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.template-select .form-select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.template-params {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.template-param-title {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.template-param-content {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
210
src/views/knowledge/aiFlow/panels/QuestionPanel.vue
Normal file
210
src/views/knowledge/aiFlow/panels/QuestionPanel.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入参数配置 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>输入变量</span>
|
||||
<el-button type="primary" size="small" @click="addParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类列表配置 -->
|
||||
<div class="mb-4 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>问题分类</span>
|
||||
<el-button type="primary" size="small" @click="addClass">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(item, index) in node.questionParams?.categories" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="item.name" placeholder="分类名称" disabled>
|
||||
<template #prepend>分类</template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-input v-model="item.value" placeholder="请输入主题内容" />
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeClass(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型参数配置 -->
|
||||
<div class="mb-4 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>模型配置</span>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<div class="param-item param-item-margin">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">调度模型:</span>
|
||||
<model-select v-model="modelConfig.model" :type="['Chat']" class="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-item param-item-margin">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="最大 Tokens">
|
||||
<el-input-number v-model="modelConfig.max_tokens" :min="1" :max="100" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Temperature">
|
||||
<el-input-number v-model="modelConfig.temperature" :min="0" :max="2" :step="0.1" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Top P">
|
||||
<el-input-number v-model="modelConfig.top_p" :min="0" :max="1" :step="0.1" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 输出变量展示 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-header">
|
||||
<span>输出变量</span>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(output, index) in outputParams" :key="index" class="mb-4">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-text> 变量名: </el-text>
|
||||
<el-tag>{{ output.name }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-text>|</el-text>
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-text> 变量类型: </el-text>
|
||||
<el-tag>{{ output.type }}</el-tag>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import { reactive } from 'vue';
|
||||
import common from './common.ts';
|
||||
import './panel.css';
|
||||
import ModelSelect from '../components/ModelSelect.vue';
|
||||
|
||||
export default {
|
||||
name: 'QuestionPanel',
|
||||
inject: ['parent'],
|
||||
components: {
|
||||
Plus,
|
||||
Delete,
|
||||
ModelSelect,
|
||||
},
|
||||
mixins: [common],
|
||||
props: {
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const modelConfig = reactive(
|
||||
props.node.questionParams.modelConfig || {
|
||||
model: '',
|
||||
max_tokens: 50,
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
}
|
||||
);
|
||||
|
||||
if (!props.node.questionParams.modelConfig) {
|
||||
props.node.questionParams.modelConfig = modelConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
modelConfig,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
addClass() {
|
||||
if (!this.node.questionParams?.categories) {
|
||||
this.node.questionParams.categories = [];
|
||||
}
|
||||
this.node.questionParams.categories.push({
|
||||
name: `${this.node.questionParams.categories.length + 1}`,
|
||||
value: '',
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.parent.updateNodeConnections(this.node);
|
||||
});
|
||||
},
|
||||
removeClass(index) {
|
||||
this.node.questionParams.categories.splice(index, 1);
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.parent.updateNodeConnections(this.node, index);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 组件特定样式可以在这里添加 */
|
||||
</style>
|
||||
144
src/views/knowledge/aiFlow/panels/RagPanel.vue
Normal file
144
src/views/knowledge/aiFlow/panels/RagPanel.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入变量部分 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>变量输入</span>
|
||||
<el-button @click="addParam" type="primary" size="small">
|
||||
<el-icon><Plus /></el-icon>添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeParam(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 知识库选择 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>知识库选择</span>
|
||||
</div>
|
||||
<el-select v-model="node.ragParams.datasetId" class="w-full" placeholder="请选择知识库" @change="handleDatasetChange">
|
||||
<el-option value="" label="请选择知识库" />
|
||||
<el-option v-for="dataset in datasetList" :key="dataset.id" :value="dataset.id" :label="dataset.name" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">仅召回<tip content="如果设置为【是】,则只查询向量" /></div>
|
||||
<el-radio-group v-model="node.ragParams.onlyRecall">
|
||||
<el-radio border label="0">否</el-radio>
|
||||
<el-radio border label="1">是</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>提示词</span>
|
||||
</div>
|
||||
<code-editor v-model="node.ragParams.prompt" :json="false" :readonly="false" theme="nord" height="250px" />
|
||||
</div>
|
||||
|
||||
<!-- 输出变量 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-header">
|
||||
<span>输出变量</span>
|
||||
</div>
|
||||
<div class="params-list">
|
||||
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="10">
|
||||
<el-text> 变量名: </el-text>
|
||||
<el-tag>{{ output.name }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-text>|</el-text>
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-text> 变量类型: </el-text>
|
||||
<el-tag>{{ output.type }}</el-tag>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import common from './common.ts';
|
||||
import './panel.css';
|
||||
import { fetchDataList } from '/@/api/knowledge/aiDataset';
|
||||
import CodeEditor from '/@/views/knowledge/aiFlow/components/CodeEditor.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'RagPanel',
|
||||
components: {
|
||||
CodeEditor,
|
||||
Plus,
|
||||
Delete,
|
||||
},
|
||||
mixins: [common],
|
||||
data() {
|
||||
return {
|
||||
datasetList: [],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.loadDbList();
|
||||
},
|
||||
methods: {
|
||||
async loadDbList() {
|
||||
const { data } = await fetchDataList();
|
||||
this.datasetList = data;
|
||||
},
|
||||
handleDatasetChange() {
|
||||
if (!this.node.ragParams.datasetId) {
|
||||
this.node.ragParams.datasetId = '';
|
||||
return;
|
||||
}
|
||||
const selectedDb = this.datasetList.find((dataset) => dataset.id === this.node.ragParams.datasetId);
|
||||
|
||||
if (selectedDb) {
|
||||
this.node.ragParams.datasetId = selectedDb.id;
|
||||
this.node.ragParams.datasetName = selectedDb.name;
|
||||
}
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const result = ref(null);
|
||||
return {
|
||||
result,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 组件特定样式可以在这里添加 */
|
||||
</style>
|
||||
304
src/views/knowledge/aiFlow/panels/StartPanel.vue
Normal file
304
src/views/knowledge/aiFlow/panels/StartPanel.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 变量列表区域 -->
|
||||
<div class="panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>变量列表</span>
|
||||
<el-button type="primary" size="small" @click="addOutput">
|
||||
<el-icon>
|
||||
<Plus/>
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div>
|
||||
<el-tag type="primary" size="small">
|
||||
{{ getInputTypeLabel(param.inputType) }}
|
||||
</el-tag>
|
||||
<el-text>
|
||||
|
|
||||
</el-text>
|
||||
<el-tag :type="param.required ? 'danger' : 'info'" size="small">
|
||||
{{ param.required ? '必填' : '选填' }}
|
||||
</el-tag>
|
||||
<el-text>
|
||||
|
|
||||
</el-text>
|
||||
<el-text>
|
||||
{{ param.name }} ({{ param.type }})
|
||||
</el-text>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!param.disabled" class="flex gap-2">
|
||||
<el-button type="primary" size="small" @click="editParam(index)">
|
||||
<el-icon>
|
||||
<Edit/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" @click="removeOutput(index)">
|
||||
<el-icon>
|
||||
<Delete/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变量编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="editDialogVisible"
|
||||
:title="isEdit ? '编辑变量' : '添加变量'"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="paramForm"
|
||||
:model="editingParam"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
>
|
||||
<el-form-item label="显示名称" prop="name">
|
||||
<el-input v-model="editingParam.name" placeholder="请输入显示名称"/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="变量名" prop="type">
|
||||
<el-input v-model="editingParam.type" placeholder="请输入变量名"/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="输入类型" prop="inputType">
|
||||
<el-select
|
||||
v-model="editingParam.inputType"
|
||||
class="w-full"
|
||||
@change="handleEditInputTypeChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in inputTypeDict"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="是否必填" prop="required">
|
||||
<el-select v-model="editingParam.required" class="w-full">
|
||||
<el-option :value="false" label="否"/>
|
||||
<el-option :value="true" label="是"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 选项编辑表单 -->
|
||||
<template v-if="editingParam.inputType==='select'">
|
||||
<el-divider content-position="left">选项配置</el-divider>
|
||||
<div class="options-list">
|
||||
<div v-for="(option, index) in editingParam.editingOptions" :key="index" class="mb-2">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-form-item
|
||||
:prop="'editingOptions.' + index + '.label'"
|
||||
:rules="{ required: true, message: '请输入选项名称', trigger: 'blur' }"
|
||||
>
|
||||
<el-input v-model="option.label" placeholder="请输入选项名称"/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item
|
||||
:prop="'editingOptions.' + index + '.value'"
|
||||
:rules="{ required: true, message: '请输入选项值', trigger: 'blur' }"
|
||||
>
|
||||
<el-input v-model="option.value" placeholder="请输入选项值"/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeOption(index)">
|
||||
<el-icon>
|
||||
<Delete/>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<el-button type="primary" @click="addOption">
|
||||
<el-icon>
|
||||
<Plus/>
|
||||
</el-icon>
|
||||
添加选项
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {Plus, Delete, Edit} from '@element-plus/icons-vue'
|
||||
import common from './common.ts'
|
||||
|
||||
export default {
|
||||
name: 'StartPanel',
|
||||
components: {
|
||||
Plus,
|
||||
Delete,
|
||||
Edit
|
||||
},
|
||||
mixins: [common],
|
||||
data() {
|
||||
return {
|
||||
inputTypeDict: [
|
||||
{label: '输入框', value: 'input'},
|
||||
{label: '下拉框', value: 'select'},
|
||||
{label: '数字框', value: 'number'},
|
||||
{label: '文本框', value: 'textarea'},
|
||||
{label: '图片', value: 'image'},
|
||||
],
|
||||
editDialogVisible: false,
|
||||
editingParam: {
|
||||
name: '',
|
||||
type: '',
|
||||
value: '',
|
||||
required: false,
|
||||
inputType: 'input',
|
||||
options: [],
|
||||
editingOptions: []
|
||||
},
|
||||
isEdit: false,
|
||||
rules: {
|
||||
name: [
|
||||
{required: true, message: '请输入显示名称', trigger: 'blur'},
|
||||
{min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur'}
|
||||
],
|
||||
type: [
|
||||
{required: true, message: '请输入变量名', trigger: 'blur'},
|
||||
{
|
||||
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
|
||||
message: '变量名只能包含字母、数字和下划线,且必须以字母开头',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
inputType: [
|
||||
{required: true, message: '请选择输入类型', trigger: 'change'}
|
||||
],
|
||||
required: [
|
||||
{required: true, message: '请选择是否必填', trigger: 'change'}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateOutputParams()
|
||||
},
|
||||
methods: {
|
||||
addOutput() {
|
||||
this.isEdit = false
|
||||
this.editingParam = {
|
||||
name: '',
|
||||
type: '',
|
||||
value: '',
|
||||
required: false,
|
||||
inputType: 'input',
|
||||
options: [],
|
||||
editingOptions: []
|
||||
}
|
||||
this.editDialogVisible = true
|
||||
},
|
||||
editParam(index) {
|
||||
this.isEdit = true
|
||||
this.editingParamIndex = index
|
||||
const param = {...this.inputParams[index]}
|
||||
param.editingOptions = [...(param.options || [])].map(opt => ({...opt}))
|
||||
this.editingParam = param
|
||||
this.editDialogVisible = true
|
||||
},
|
||||
saveParam() {
|
||||
if (this.isEdit) {
|
||||
this.inputParams[this.editingParamIndex] = {...this.editingParam}
|
||||
} else {
|
||||
this.inputParams.push({...this.editingParam})
|
||||
}
|
||||
this.editDialogVisible = false
|
||||
},
|
||||
handleEditInputTypeChange() {
|
||||
if (this.editingParam.inputType === 'select' && (!this.editingParam.options || this.editingParam.options.length === 0)) {
|
||||
this.editingParam.options = []
|
||||
}
|
||||
},
|
||||
removeOutput(index) {
|
||||
this.inputParams.splice(index, 1)
|
||||
},
|
||||
updateOutputParams() {
|
||||
const outputParams = this.inputParams.map(param => ({
|
||||
name: param.type || '',
|
||||
type: param.type || '',
|
||||
value: '',
|
||||
required: param.required || false,
|
||||
inputType: param.inputType || 'input',
|
||||
options: param.options || []
|
||||
}))
|
||||
|
||||
if (this.node) {
|
||||
this.node.outputParams = outputParams
|
||||
}
|
||||
},
|
||||
handleSave() {
|
||||
this.$refs.paramForm.validate(async (valid) => {
|
||||
if (valid) {
|
||||
if (this.editingParam.inputType === 'select') {
|
||||
this.editingParam.options = [...this.editingParam.editingOptions]
|
||||
}
|
||||
|
||||
if (this.isEdit) {
|
||||
this.inputParams[this.editingParamIndex] = {...this.editingParam}
|
||||
} else {
|
||||
this.inputParams.push({...this.editingParam})
|
||||
}
|
||||
this.editDialogVisible = false
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
},
|
||||
addOption() {
|
||||
this.editingParam.editingOptions.push({
|
||||
label: '',
|
||||
value: ''
|
||||
})
|
||||
},
|
||||
removeOption(index) {
|
||||
this.editingParam.editingOptions.splice(index, 1)
|
||||
},
|
||||
getInputTypeLabel(type) {
|
||||
const found = this.inputTypeDict.find(item => item.value === type)
|
||||
return found ? found.label : '输入框'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
inputParams: {
|
||||
handler() {
|
||||
this.updateOutputParams()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 组件特定样式可以在这里添加 */
|
||||
</style>
|
||||
167
src/views/knowledge/aiFlow/panels/SwitchPanel.vue
Normal file
167
src/views/knowledge/aiFlow/panels/SwitchPanel.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入参数配置 -->
|
||||
<div class="panel-section mb-2">
|
||||
<div class="panel-header flex justify-between items-center">
|
||||
<span>输入变量</span>
|
||||
<el-button type="primary" size="small" @click="addParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值" class="w-full">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码编辑区域 -->
|
||||
<div class="panel-section mb-2">
|
||||
<div class="panel-header">
|
||||
<span>分支判断代码</span>
|
||||
</div>
|
||||
<code-editor v-model="node.switchParams.code" :json="false" :readonly="false" theme="nord" height="250px" />
|
||||
</div>
|
||||
|
||||
<!-- 分支列表配置 -->
|
||||
<div class="panel-section mb-2">
|
||||
<div class="panel-header flex justify-between items-center">
|
||||
<span>分支列表</span>
|
||||
<el-button type="primary" size="small" @click="addCase">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(item, index) in node.switchParams.cases" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="item.name" placeholder="分支名称" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-input v-model="item.value" placeholder="分支值" />
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeCase(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出变量 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-header">
|
||||
<span>输出变量</span>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-text> 变量名: </el-text>
|
||||
<el-tag>{{ output.name }}</el-tag>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-text>|</el-text>
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-text> 变量类型: </el-text>
|
||||
<el-tag>{{ output.type }}</el-tag>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import common from './common.ts';
|
||||
import './panel.css';
|
||||
import CodeEditor from '/@/views/knowledge/aiFlow/components/CodeEditor.vue';
|
||||
|
||||
export default {
|
||||
name: 'SwitchPanel',
|
||||
inject: ['parent'],
|
||||
components: {
|
||||
CodeEditor,
|
||||
Plus,
|
||||
Delete,
|
||||
},
|
||||
mixins: [common],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
props: {
|
||||
node: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addCase() {
|
||||
if (!this.node.switchParams.cases) {
|
||||
this.node.switchParams.cases = [];
|
||||
}
|
||||
this.node.switchParams.cases.push({
|
||||
name: `分支${this.node.switchParams.cases.length + 1}`,
|
||||
value: this.node.switchParams.cases.length,
|
||||
});
|
||||
|
||||
// 通知父组件更新连线
|
||||
this.$nextTick(() => {
|
||||
this.parent.updateNodeConnections(this.node);
|
||||
});
|
||||
},
|
||||
removeCase(index) {
|
||||
this.node.switchParams.cases.splice(index, 1);
|
||||
|
||||
// 通知父组件更新连线
|
||||
this.$nextTick(() => {
|
||||
this.parent.updateNodeConnections(this.node, index);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 组件特定样式可以在这里添加 */
|
||||
</style>
|
||||
139
src/views/knowledge/aiFlow/panels/TextPanel.vue
Normal file
139
src/views/knowledge/aiFlow/panels/TextPanel.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="panel-content">
|
||||
<!-- 输入变量部分 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="flex justify-between items-center panel-header">
|
||||
<span>变量输入</span>
|
||||
<el-button type="primary" size="small" @click="addParam">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
|
||||
<div class="param-item">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-select v-model="param.type" placeholder="变量值">
|
||||
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
|
||||
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button @click="removeParam(index)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本内容编辑 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>文本内容</span>
|
||||
</div>
|
||||
<div class="text-editor">
|
||||
<el-input
|
||||
v-model="node.textParams.content"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="请输入要返回的固定文本内容..."
|
||||
show-word-limit
|
||||
maxlength="5000"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-tips">
|
||||
<el-alert
|
||||
title="提示:此节点将返回您设置的固定文本内容,可用于流程中的固定消息、说明文字等场景。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出变量配置 -->
|
||||
<div class="mb-2 panel-section">
|
||||
<div class="panel-header">
|
||||
<span>输出变量</span>
|
||||
</div>
|
||||
<div class="params-list">
|
||||
<div v-for="(param, index) in outputParams" :key="index" class="mb-2">
|
||||
<div class="param-item readonly">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="9">
|
||||
<el-input v-model="param.name" :disabled="true" placeholder="变量名" />
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-input v-model="param.type" :disabled="true" placeholder="变量类型" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import common from './common.ts';
|
||||
import './panel.css';
|
||||
|
||||
export default {
|
||||
name: 'TextPanel',
|
||||
components: {
|
||||
Plus,
|
||||
Delete,
|
||||
},
|
||||
mixins: [common],
|
||||
watch: {
|
||||
// 监听节点参数变化,确保节点有正确的默认参数
|
||||
'node.textParams': {
|
||||
handler() {
|
||||
if (!this.node.textParams) {
|
||||
this.$set(this.node, 'textParams', { content: '' });
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
created() {
|
||||
// 确保节点有textParams属性
|
||||
if (!this.node.textParams) {
|
||||
this.$set(this.node, 'textParams', { content: '' });
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-editor {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.text-tips {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.param-item.readonly {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.param-item.readonly .el-input {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
</style>
|
||||
122
src/views/knowledge/aiFlow/panels/common.ts
Normal file
122
src/views/knowledge/aiFlow/panels/common.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Node, ParamItem } from '../types/node';
|
||||
import { PanelComponent, PreviousNodeOutput } from '../types/panel';
|
||||
|
||||
export default {
|
||||
inject: ['parent'],
|
||||
props: {
|
||||
node: {
|
||||
type: Object as () => Node,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inputParams: this.node.inputParams || [],
|
||||
outputParams: this.node.outputParams || [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 获取所有上级节点的输入参数
|
||||
previousInputParams(): Array<{ id: string; name: string; list: ParamItem[] }> {
|
||||
const previousNodes = this.previousNodes;
|
||||
return previousNodes.map((node) => {
|
||||
const { id, name, inputParams = [] } = node;
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
list: inputParams || [],
|
||||
};
|
||||
});
|
||||
},
|
||||
// 获取所有上级节点的输出参数和全局环境变量
|
||||
previousOutputParams(): PreviousNodeOutput[] {
|
||||
const previousNodes = this.previousNodes;
|
||||
const nodeOutputs = previousNodes.map((node) => {
|
||||
const { id, name, outputParams = [] } = node;
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
list: outputParams,
|
||||
};
|
||||
});
|
||||
|
||||
// 添加全局环境变量
|
||||
if ((this as any).parent.env && (this as any).parent.env.length) {
|
||||
nodeOutputs.push({
|
||||
id: 'global',
|
||||
name: '全局环境变量',
|
||||
list: (this as any).parent.env.map((env: { name: string; value: any }) => ({
|
||||
name: env.name,
|
||||
value: 'global',
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return nodeOutputs;
|
||||
},
|
||||
// 递归获取所有上级节点
|
||||
previousNodes(): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
const findPreviousNodes = (nodeId: string): void => {
|
||||
const connection = (this as any).parent.connections.find((conn: { targetId: string }) => conn.targetId === nodeId);
|
||||
if (!connection) return;
|
||||
|
||||
const node = (this as any).parent.nodes.find((node: Node) => node.id === connection.sourceId);
|
||||
if (node) {
|
||||
nodes.push(node);
|
||||
findPreviousNodes(node.id);
|
||||
}
|
||||
};
|
||||
|
||||
findPreviousNodes(this.node.id);
|
||||
return nodes;
|
||||
},
|
||||
nextNode(): Node {
|
||||
const connection = (this as any).parent.connections.find((conn: { sourceId: string }) => conn.sourceId === this.node.id);
|
||||
if (!connection) return {} as Node;
|
||||
return (this as any).parent.nodes.find((node: Node) => node.id === connection.targetId) || ({} as Node);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
outputParams: {
|
||||
deep: true,
|
||||
handler(): void {
|
||||
this.updateVariables();
|
||||
},
|
||||
},
|
||||
inputParams: {
|
||||
deep: true,
|
||||
handler(): void {
|
||||
this.updateVariables();
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addParam(): void {
|
||||
this.inputParams.push({
|
||||
name: `arg${this.inputParams.length + 1}`,
|
||||
type: '',
|
||||
});
|
||||
},
|
||||
addOutput(): void {
|
||||
this.outputParams.push({
|
||||
name: `result${this.outputParams.length + 1}`,
|
||||
type: '',
|
||||
});
|
||||
},
|
||||
|
||||
removeParam(index: number): void {
|
||||
this.inputParams.splice(index, 1);
|
||||
},
|
||||
|
||||
removeOutput(index: number): void {
|
||||
this.outputParams.splice(index, 1);
|
||||
},
|
||||
updateVariables(): void {
|
||||
this.node.outputParams = [...this.outputParams];
|
||||
this.node.inputParams = [...this.inputParams];
|
||||
(this as any).$emit('update:node', this.node);
|
||||
},
|
||||
},
|
||||
};
|
||||
250
src/views/knowledge/aiFlow/panels/panel.css
Normal file
250
src/views/knowledge/aiFlow/panels/panel.css
Normal file
@@ -0,0 +1,250 @@
|
||||
.el-drawer__header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 面板基础布局 */
|
||||
.panel-content {
|
||||
padding: 12px;
|
||||
height: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.2rem;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 输入组样式 */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表单控件样式 */
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
overflow: hidden;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
color: #333;
|
||||
border: none;
|
||||
background-color: rgb(242, 244, 247);
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-input:hover,
|
||||
.form-select:hover,
|
||||
.form-textarea:hover {
|
||||
border-color: #c0c4cc;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-input::placeholder,
|
||||
.form-select::placeholder,
|
||||
.form-textarea::placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
/* 参数区域样式 */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.params-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/**gap: 6px;**/
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.param-item-margin {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* 删除按钮样式 */
|
||||
.delete-btn {
|
||||
padding: 4px !important;
|
||||
height: 28px !important;
|
||||
width: 28px !important;
|
||||
min-width: 28px !important;
|
||||
flex-shrink: 0 !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
/* 添加按钮样式 */
|
||||
.add-btn {
|
||||
padding: 6px 12px;
|
||||
background: #1890ff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
/* 输入包装器样式 */
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0; /* 防止内容溢出 */
|
||||
}
|
||||
|
||||
.input-wrapper.type-wrapper {
|
||||
flex: 0.5;
|
||||
}
|
||||
|
||||
/* 模型配置样式 */
|
||||
.model-config {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.model-config .param-item {
|
||||
margin-bottom: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.model-config label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 下拉框组样式 */
|
||||
.form-select optgroup {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.form-select option {
|
||||
padding: 6px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 只读输出样式 */
|
||||
.param-item span.form-input,
|
||||
.param-item span.form-select {
|
||||
padding: 2px 10px;
|
||||
background: #f8f9fb;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.form-input.error,
|
||||
.form-select.error {
|
||||
border-color: #f56c6c;
|
||||
background-color: #fef0f0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 12px;
|
||||
color: #f56c6c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 禁用状态 */
|
||||
.form-input:disabled,
|
||||
.form-select:disabled,
|
||||
.form-textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 模板相关样式 */
|
||||
.template-select {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.template-params {
|
||||
padding: 1rem;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 拖拽相关样式 */
|
||||
.cursor-move {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.panel-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.panel-content::-webkit-scrollbar-track {
|
||||
background: #f8f9fb;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.panel-content::-webkit-scrollbar-thumb {
|
||||
background: #dcdfe6;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.panel-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #c0c4cc;
|
||||
}
|
||||
609
src/views/knowledge/aiFlow/styles/flow.scss
Normal file
609
src/views/knowledge/aiFlow/styles/flow.scss
Normal file
@@ -0,0 +1,609 @@
|
||||
.node-icon {
|
||||
margin-right: 8px;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
&--start {
|
||||
background-color: rgb(41, 109, 255);
|
||||
}
|
||||
&--http {
|
||||
background-color: rgb(135, 91, 247);
|
||||
}
|
||||
&--llm {
|
||||
background-color: rgb(97, 114, 233);
|
||||
}
|
||||
&--code {
|
||||
background-color: rgb(46, 114, 250);
|
||||
}
|
||||
&--db {
|
||||
background-color: rgb(0, 206, 209);
|
||||
}
|
||||
&--switch {
|
||||
background-color: rgb(255, 187, 0);
|
||||
}
|
||||
&--condition {
|
||||
background-color: rgb(0, 206, 209);
|
||||
}
|
||||
&--question {
|
||||
background-color: rgb(27, 138, 106);
|
||||
}
|
||||
&--rag {
|
||||
background-color: rgb(75, 107, 251);
|
||||
}
|
||||
&--notice {
|
||||
background-color: rgb(64, 158, 255);
|
||||
}
|
||||
&--mcp {
|
||||
background-color: rgb(27, 138, 106);
|
||||
}
|
||||
&--text {
|
||||
background-color: rgb(27, 138, 106);
|
||||
}
|
||||
&--end {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.variables-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.variables-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
margin: 0 -4px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
/* 滚动条样式 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.variable-item {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 8px;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.variable-actions {
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variable-inputs {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
/* 滚动条样式 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.input-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input-label {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-value {
|
||||
flex: 1;
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
height: 36px;
|
||||
}
|
||||
.required::before {
|
||||
content: '*';
|
||||
color: #f56c6c;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
input:hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
border-color: #c0c4cc;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
background-color: #f5f7fa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 文本域样式 */
|
||||
textarea {
|
||||
resize: none;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.node-execution {
|
||||
min-width: 300px;
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.node-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.node-type {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.node-name {
|
||||
background-color: transparent;
|
||||
font-size: 12px;
|
||||
width: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-time {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 状态样式 */
|
||||
.running {
|
||||
background: #e6f7ff;
|
||||
border-color: #91d5ff;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fff2f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.skipped {
|
||||
background: #f5f5f5;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.pending {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.trace-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.io-container {
|
||||
margin-top: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.io-section {
|
||||
margin-bottom: 8px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.io-header {
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.io-header:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.io-content {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.io-content pre {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: #333;
|
||||
font-family: Monaco, Menlo, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 添加动画效果 */
|
||||
.io-content {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.is-rotate {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* 添加节点状态文本样式 */
|
||||
.node-status-text {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.node-status-text.running {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.node-status-text.success {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.node-status-text.error {
|
||||
background: #fff2f0;
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.node-status-text.skipped {
|
||||
background: #f5f5f5;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.node-status-text.pending {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 修改加载图标样式 */
|
||||
.is-loading {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 工作流页面通用布局
|
||||
.workflow {
|
||||
&-logs,
|
||||
&-service,
|
||||
&-check {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background-color: #f5f7fa;
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.section {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
h2 {
|
||||
margin: 0 0 20px;
|
||||
font-size: 18px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 24px 0 16px;
|
||||
font-size: 16px;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表格通用样式
|
||||
.custom-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: #fff;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
td {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 32px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗通用样式
|
||||
.custom-dialog-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.custom-dialog {
|
||||
width: 60%;
|
||||
max-width: 900px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
font-size: 22px;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 24px;
|
||||
max-height: calc(90vh - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 详情内容通用样式
|
||||
.detail-content {
|
||||
.detail-item {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #4b5563;
|
||||
|
||||
pre {
|
||||
background-color: #f8f9fa;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: Monaco, Menlo, Consolas, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
&.error-message {
|
||||
pre {
|
||||
color: #f56c6c;
|
||||
background-color: #fef0f0;
|
||||
border-color: #fde2e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API文档样式
|
||||
.api-info {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.label {
|
||||
width: 80px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.api-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
code {
|
||||
color: #333;
|
||||
background-color: #f3f4f6;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background-color: #1f2937;
|
||||
color: #e5e7eb;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.required-field {
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
147
src/views/knowledge/aiFlow/types/executor.ts
Normal file
147
src/views/knowledge/aiFlow/types/executor.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 执行器类型定义
|
||||
*/
|
||||
import { Node, ExecutionOptions } from './node';
|
||||
import { InputParams, Params } from './utils';
|
||||
|
||||
/**
|
||||
* 节点执行结果接口
|
||||
*/
|
||||
export interface NodeExecutionResponse {
|
||||
nodeId: string;
|
||||
type: string;
|
||||
result: any;
|
||||
timestamp: number;
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码节点参数接口
|
||||
*/
|
||||
export interface CodeNodeParams {
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库节点参数接口
|
||||
*/
|
||||
export interface DbNodeParams {
|
||||
dbId: string | number;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP节点参数接口
|
||||
*/
|
||||
export interface HttpNodeParams {
|
||||
url: string;
|
||||
method: string;
|
||||
contentType: string;
|
||||
jsonBody?: string;
|
||||
headerParams?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
bodyParams?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
paramsParams?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM节点参数接口
|
||||
*/
|
||||
export interface LLMNodeParams {
|
||||
modelConfig: Record<string, any>;
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知节点参数接口
|
||||
*/
|
||||
export interface NoticeNodeParams {
|
||||
templateCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 问题节点参数接口
|
||||
*/
|
||||
export interface QuestionNodeParams {
|
||||
question: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch节点参数接口
|
||||
*/
|
||||
export interface SwitchNodeParams {
|
||||
cases: Array<{
|
||||
value: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码节点接口
|
||||
*/
|
||||
export interface CodeNode extends Node {
|
||||
codeParams: CodeNodeParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库节点接口
|
||||
*/
|
||||
export interface DbNode extends Node {
|
||||
dbParams: DbNodeParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP节点接口
|
||||
*/
|
||||
export interface HttpNode extends Node {
|
||||
httpParams: HttpNodeParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM节点接口
|
||||
*/
|
||||
export interface LLMNode extends Node {
|
||||
llmParams: LLMNodeParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知节点接口
|
||||
*/
|
||||
export interface NoticeNode extends Node {
|
||||
noticeParams: NoticeNodeParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 问题节点接口
|
||||
*/
|
||||
export interface QuestionNode extends Node {
|
||||
questionParams: QuestionNodeParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch节点接口
|
||||
*/
|
||||
export interface SwitchNode extends Node {
|
||||
switchParams: SwitchNodeParams;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行器函数类型
|
||||
*/
|
||||
export type ExecutorFunction<T extends Node> = (
|
||||
node: T,
|
||||
inputParams: InputParams,
|
||||
nodes: Node[],
|
||||
options?: ExecutionOptions
|
||||
) => Promise<NodeExecutionResponse>;
|
||||
37
src/views/knowledge/aiFlow/types/executors.ts
Normal file
37
src/views/knowledge/aiFlow/types/executors.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 节点执行器类型定义
|
||||
*/
|
||||
|
||||
import { Node } from './node';
|
||||
import { InputParams } from './utils';
|
||||
import { NodeExecutionResponse } from './executor';
|
||||
|
||||
/**
|
||||
* Debug函数类型定义
|
||||
*/
|
||||
export type DebugFunction = (
|
||||
templateCode: string,
|
||||
params: string
|
||||
) => Promise<{
|
||||
data: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
params: any;
|
||||
[key: string]: any;
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Mock问题处理函数类型定义
|
||||
*/
|
||||
export type MockQuestionProcessingFunction = (input: string) => {
|
||||
data: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
result: {
|
||||
answer: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
24
src/views/knowledge/aiFlow/types/global.d.ts
vendored
Normal file
24
src/views/knowledge/aiFlow/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
// Global declarations for AI Flow
|
||||
|
||||
// Extend the Window interface to include $glob
|
||||
interface Window {
|
||||
$glob: Record<string, any>;
|
||||
}
|
||||
|
||||
// Extend Vue prototype
|
||||
declare module 'vue/types/vue' {
|
||||
interface Vue {
|
||||
$route: {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
$log?: {
|
||||
warn?: (...args: any[]) => void;
|
||||
error?: (...args: any[]) => void;
|
||||
info?: (...args: any[]) => void;
|
||||
};
|
||||
$nextTick: (callback: () => void) => void;
|
||||
deepClone<T>(obj: T): T;
|
||||
}
|
||||
}
|
||||
81
src/views/knowledge/aiFlow/types/node.ts
Normal file
81
src/views/knowledge/aiFlow/types/node.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// Node types for AI Flow
|
||||
|
||||
// Basic node interface
|
||||
export interface Node {
|
||||
id: string;
|
||||
type: string;
|
||||
name?: string;
|
||||
inputParams?: ParamItem[];
|
||||
outputParams?: ParamItem[];
|
||||
bodyParams?: ParamItem[];
|
||||
headerParams?: ParamItem[];
|
||||
_branchIndex?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Parameter item interface
|
||||
export interface ParamItem {
|
||||
name?: string;
|
||||
type?: string;
|
||||
value?: any;
|
||||
key?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Connection between nodes
|
||||
export interface Connection {
|
||||
id?: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
portIndex?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Execution node with status
|
||||
export interface ExecutionNode extends Node {
|
||||
status: 'pending' | 'running' | 'success' | 'error' | 'skipped';
|
||||
startTime?: number;
|
||||
duration?: number;
|
||||
tokens?: number;
|
||||
input?: any;
|
||||
output?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Context for node execution
|
||||
export interface ExecutionContext {
|
||||
variables: Record<string, any>;
|
||||
params: Record<string, any>;
|
||||
envs: Record<string, any>;
|
||||
}
|
||||
|
||||
// Result of node execution
|
||||
export interface NodeExecutionResult {
|
||||
result: Record<string, any>;
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
// Options for node execution
|
||||
export interface ExecutionOptions {
|
||||
appId?: string;
|
||||
conversationId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Node mixin data interface
|
||||
export interface NodeData {
|
||||
conversationId: string;
|
||||
isRunning: boolean;
|
||||
id: string | null;
|
||||
form: Record<string, any>;
|
||||
env: Array<{ name: string; value: any }>;
|
||||
nodes: Node[];
|
||||
connections: Connection[];
|
||||
executionNodes?: ExecutionNode[];
|
||||
executionResult?: any;
|
||||
executionTime?: string;
|
||||
totalTokens?: number;
|
||||
startNodeParams?: ParamItem[];
|
||||
showExecutionPanel?: boolean;
|
||||
isStream?: boolean;
|
||||
}
|
||||
27
src/views/knowledge/aiFlow/types/panel.ts
Normal file
27
src/views/knowledge/aiFlow/types/panel.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Panel types for AI Flow
|
||||
|
||||
import { Node, Connection, ParamItem } from './node';
|
||||
|
||||
// Panel component interface
|
||||
export interface PanelComponent {
|
||||
parent: {
|
||||
nodes: Node[];
|
||||
connections: Connection[];
|
||||
env?: Array<{ name: string; value: any }>;
|
||||
};
|
||||
node: Node;
|
||||
$emit: (event: string, ...args: any[]) => void;
|
||||
}
|
||||
|
||||
// Node with params
|
||||
export interface NodeWithParams extends Node {
|
||||
inputParams: ParamItem[];
|
||||
outputParams: ParamItem[];
|
||||
}
|
||||
|
||||
// Previous node output structure
|
||||
export interface PreviousNodeOutput {
|
||||
id: string;
|
||||
name: string;
|
||||
list: ParamItem[];
|
||||
}
|
||||
41
src/views/knowledge/aiFlow/types/utils.ts
Normal file
41
src/views/knowledge/aiFlow/types/utils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 工具类型定义
|
||||
*/
|
||||
|
||||
// flow.ts 类型定义
|
||||
export interface FlowClass {
|
||||
runUrl(type: string, id: string): string;
|
||||
getIcon(icon?: string): string;
|
||||
}
|
||||
|
||||
// formatter.ts 类型定义
|
||||
export interface FormatterClass {
|
||||
prettyCode(str: string): string;
|
||||
}
|
||||
|
||||
// utils.ts 类型定义
|
||||
export interface ProcessInputParamsNode {
|
||||
id: string;
|
||||
inputParams?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
outputParams?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InputParams {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Params {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// validate.ts 类型定义
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message: string;
|
||||
}
|
||||
22
src/views/knowledge/aiFlow/utils/flow.ts
Normal file
22
src/views/knowledge/aiFlow/utils/flow.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { FlowClass } from '../types/utils';
|
||||
|
||||
export default class Flow implements FlowClass {
|
||||
/**
|
||||
* 获取统一的URL参数
|
||||
* @param type - 流程类型
|
||||
* @param id - 流程ID
|
||||
* @returns 格式化的URL字符串
|
||||
*/
|
||||
static runUrl(type: string, id: string): string {
|
||||
return `/flow/${type}/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取icon地址
|
||||
* @param icon - 图标路径
|
||||
* @returns 图标路径或默认图标
|
||||
*/
|
||||
static getIcon(icon?: string): string {
|
||||
return icon ? icon : '/img/chat/icon.png';
|
||||
}
|
||||
}
|
||||
45
src/views/knowledge/aiFlow/utils/formatter.ts
Normal file
45
src/views/knowledge/aiFlow/utils/formatter.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { validatenull } from './validate';
|
||||
import { FormatterClass } from '../types/utils';
|
||||
|
||||
/**
|
||||
* 格式化工具类
|
||||
*/
|
||||
export default class Formatter implements FormatterClass {
|
||||
/**
|
||||
* 格式化JSON代码
|
||||
* @param str - 需要格式化的JSON字符串
|
||||
* @returns 格式化后的HTML字符串
|
||||
*/
|
||||
static prettyCode(str: string): string {
|
||||
try {
|
||||
// 为空则返回空
|
||||
if (validatenull(str)) {
|
||||
return '';
|
||||
}
|
||||
// 解析并格式化JSON字符串
|
||||
str = JSON.stringify(JSON.parse(str), null, 2);
|
||||
|
||||
// 使用HTML实体进行替换(不改变&符号)
|
||||
str = str.replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 返回格式化的字符串,并添加样式类
|
||||
return str.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
|
||||
let cls = 'number';
|
||||
if (/^"/.test(match)) {
|
||||
if (/:$/.test(match)) {
|
||||
cls = 'key';
|
||||
} else {
|
||||
cls = 'string';
|
||||
}
|
||||
} else if (/true|false/.test(match)) {
|
||||
cls = 'boolean';
|
||||
} else if (/null/.test(match)) {
|
||||
cls = 'null';
|
||||
}
|
||||
return match;
|
||||
});
|
||||
} catch (e) {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/views/knowledge/aiFlow/utils/utils.ts
Normal file
106
src/views/knowledge/aiFlow/utils/utils.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ProcessInputParamsNode, InputParams, Params } from '../types/utils';
|
||||
|
||||
/**
|
||||
* 处理输入参数,将对象类型参数转换为字符串
|
||||
* @param node - 当前节点
|
||||
* @param inputParams - 输入的参数对象
|
||||
* @param nodes - 所有节点列表
|
||||
* @returns 处理后的参数对象
|
||||
*/
|
||||
export const processInputParams = (node: ProcessInputParamsNode, inputParams: InputParams, nodes: ProcessInputParamsNode[]): Params => {
|
||||
const params: Params = {};
|
||||
node.inputParams?.forEach((item) => {
|
||||
const { name, type } = item;
|
||||
let sourceType: string | undefined;
|
||||
// 将type按照.分割成数组,获取节点id和参数key
|
||||
const [id, key] = type?.split('.') || [];
|
||||
let sourceNode = nodes.find((n) => n.id === id);
|
||||
if (sourceNode) {
|
||||
const sourceParams = sourceNode.outputParams?.find((n) => n.name == key);
|
||||
if (sourceParams) {
|
||||
sourceType = sourceParams.type;
|
||||
}
|
||||
}
|
||||
switch (sourceType) {
|
||||
case 'String':
|
||||
params[name] = typeof inputParams[type] === 'object' ? JSON.stringify(inputParams[type]) : String(inputParams[type]);
|
||||
break;
|
||||
case 'Number':
|
||||
params[name] = Number(inputParams[type]);
|
||||
break;
|
||||
case 'Boolean':
|
||||
params[name] = Boolean(inputParams[type]);
|
||||
break;
|
||||
case 'Object':
|
||||
params[name] = typeof inputParams[type] === 'string' ? JSON.parse(inputParams[type]) : inputParams[type];
|
||||
break;
|
||||
case 'Array':
|
||||
params[name] = Array.isArray(inputParams[type]) ? inputParams[type] : JSON.parse(inputParams[type]);
|
||||
break;
|
||||
default:
|
||||
params[name] = inputParams[type];
|
||||
}
|
||||
});
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* 替换字符串中的变量占位符
|
||||
* @param str - 包含变量占位符的字符串
|
||||
* @param params - 变量参数对象
|
||||
* @returns 替换后的字符串
|
||||
*/
|
||||
export const replaceVariable = (str: string, params: Params): string => {
|
||||
// 检查是否存在${xxx}格式的表达式
|
||||
const regex = /\$\{([^}]+)\}/;
|
||||
const match = str.match(regex);
|
||||
|
||||
// 如果没有表达式直接返回
|
||||
if (!match) {
|
||||
return str;
|
||||
}
|
||||
|
||||
// 获取表达式内容,如 arg[0] 或 arg.data
|
||||
const expr = match[1];
|
||||
|
||||
// 解析表达式获取实际值
|
||||
let value: any;
|
||||
try {
|
||||
// 处理数组索引访问 如 arg[0]
|
||||
// 使用正则匹配复杂的属性访问表达式
|
||||
const propRegex = /^([^.\[]+)(?:\.([^.\[]+)|\[(\d+)\])*$/;
|
||||
const parts = expr.match(propRegex);
|
||||
|
||||
if (parts) {
|
||||
// 从params获取根对象
|
||||
let result = params[parts[1]];
|
||||
|
||||
// 解析剩余的属性访问路径
|
||||
const accessors = expr.slice(parts[1].length).match(/(?:\.([^.\[]+)|\[(\d+)\])/g) || [];
|
||||
|
||||
// 依次访问每个属性
|
||||
for (const accessor of accessors) {
|
||||
if (accessor.startsWith('.')) {
|
||||
// 处理点号访问 如 .data
|
||||
result = result[accessor.slice(1)];
|
||||
} else {
|
||||
// 处理数组索引访问 如 [0]
|
||||
const index = parseInt(accessor.slice(1, -1));
|
||||
result = result[index];
|
||||
}
|
||||
}
|
||||
|
||||
value = result;
|
||||
}
|
||||
// 直接变量
|
||||
else {
|
||||
value = params[expr];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`解析表达式 ${expr} 失败:`, e);
|
||||
value = '';
|
||||
}
|
||||
|
||||
// 将解析后的值替换回字符串
|
||||
return str.replace(regex, value);
|
||||
};
|
||||
318
src/views/knowledge/aiFlow/utils/validate.ts
Normal file
318
src/views/knowledge/aiFlow/utils/validate.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { ValidationResult } from '../types/utils';
|
||||
|
||||
/**
|
||||
* 验证用户名是否有效
|
||||
* @param str - 用户名
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function isvalidUsername(str: string): boolean {
|
||||
const valid_map = ['admin', 'editor'];
|
||||
return valid_map.indexOf(str.trim()) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证URL是否合法
|
||||
* @param textval - URL字符串
|
||||
* @returns 是否合法
|
||||
*/
|
||||
export function validateURL(textval: string): boolean {
|
||||
const urlregex =
|
||||
/^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/;
|
||||
return urlregex.test(textval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
* @param s - 邮箱字符串
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function isEmail(s: string): boolean {
|
||||
return /^([a-zA-Z0-9_-])+@([a-zA-Z0-9_-])+((.[a-zA-Z0-9_-]{2,3}){1,2})$/.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号码
|
||||
* @param s - 手机号码
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function isMobile(s: string): boolean {
|
||||
return /^1[0-9]{10}$/.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证电话号码
|
||||
* @param s - 电话号码
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function isPhone(s: string): boolean {
|
||||
return /^([0-9]{3,4}-)?[0-9]{7,8}$/.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证URL地址
|
||||
* @param s - URL地址
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function isURL(s: string): boolean {
|
||||
return /^http[s]?:\/\/.*/.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证小写字母
|
||||
* @param str - 字符串
|
||||
* @returns 是否全为小写字母
|
||||
*/
|
||||
export function validateLowerCase(str: string): boolean {
|
||||
const reg = /^[a-z]+$/;
|
||||
return reg.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证大写字母
|
||||
* @param str - 字符串
|
||||
* @returns 是否全为大写字母
|
||||
*/
|
||||
export function validateUpperCase(str: string): boolean {
|
||||
const reg = /^[A-Z]+$/;
|
||||
return reg.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证大小写字母
|
||||
* @param str - 字符串
|
||||
* @returns 是否全为字母
|
||||
*/
|
||||
export function validatAlphabets(str: string): boolean {
|
||||
const reg = /^[A-Za-z]+$/;
|
||||
return reg.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证是否为PC设备
|
||||
* @returns 是否为PC设备
|
||||
*/
|
||||
export const vaildatePc = function (): boolean {
|
||||
const userAgentInfo = navigator.userAgent;
|
||||
const Agents = ['Android', 'iPhone', 'SymbianOS', 'Windows Phone', 'iPad', 'iPod'];
|
||||
let flag = true;
|
||||
for (let v = 0; v < Agents.length; v++) {
|
||||
if (userAgentInfo.indexOf(Agents[v]) > 0) {
|
||||
flag = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return flag;
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
* @param email - 邮箱地址
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function validateEmail(email: string): boolean {
|
||||
const re =
|
||||
/^(([^<>()\\[\]\\.,;:\s@"]+(\.[^<>()\\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
return re.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证身份证号码
|
||||
* @param code - 身份证号码
|
||||
* @returns 验证结果数组 [是否有效, 错误信息]
|
||||
*/
|
||||
export function cardid(code: string): [boolean, string] {
|
||||
let result = true;
|
||||
let msg = '';
|
||||
const city: { [key: string]: string } = {
|
||||
11: '北京',
|
||||
12: '天津',
|
||||
13: '河北',
|
||||
14: '山西',
|
||||
15: '内蒙古',
|
||||
21: '辽宁',
|
||||
22: '吉林',
|
||||
23: '黑龙江',
|
||||
31: '上海',
|
||||
32: '江苏',
|
||||
33: '浙江',
|
||||
34: '安徽',
|
||||
35: '福建',
|
||||
36: '江西',
|
||||
37: '山东',
|
||||
41: '河南',
|
||||
42: '湖北',
|
||||
43: '湖南',
|
||||
44: '广东',
|
||||
45: '广西',
|
||||
46: '海南',
|
||||
50: '重庆',
|
||||
51: '四川',
|
||||
52: '贵州',
|
||||
53: '云南',
|
||||
54: '西藏',
|
||||
61: '陕西',
|
||||
62: '甘肃',
|
||||
63: '青海',
|
||||
64: '宁夏',
|
||||
65: '新疆',
|
||||
71: '台湾',
|
||||
81: '香港',
|
||||
82: '澳门',
|
||||
91: '国外',
|
||||
};
|
||||
|
||||
if (!validatenull(code)) {
|
||||
if (code.length === 18) {
|
||||
if (!code || !/(^\d{18}$)|(^\d{17}(\d|X|x)$)/.test(code)) {
|
||||
msg = '证件号码格式错误';
|
||||
} else if (!city[code.substr(0, 2)]) {
|
||||
msg = '地址编码错误';
|
||||
} else {
|
||||
// 18位身份证需要验证最后一位校验位
|
||||
const codeArray = code.split('');
|
||||
// ∑(ai×Wi)(mod 11)
|
||||
// 加权因子
|
||||
const factor = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
||||
// 校验位
|
||||
const parity = [1, 0, 'X', 9, 8, 7, 6, 5, 4, 3, 2, 'x'];
|
||||
let sum = 0;
|
||||
let ai = 0;
|
||||
let wi = 0;
|
||||
for (let i = 0; i < 17; i++) {
|
||||
ai = parseInt(codeArray[i]);
|
||||
wi = factor[i];
|
||||
sum += ai * wi;
|
||||
}
|
||||
if (parity[sum % 11] !== codeArray[17]) {
|
||||
msg = '证件号码校验位错误';
|
||||
} else {
|
||||
result = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg = '证件号码长度不为18位';
|
||||
}
|
||||
} else {
|
||||
msg = '证件号码不能为空';
|
||||
}
|
||||
return [result, msg];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号码
|
||||
* @param phone - 手机号码
|
||||
* @returns 验证结果数组 [是否有效, 错误信息]
|
||||
*/
|
||||
export function isvalidatemobile(phone: string): [boolean, string] {
|
||||
let result = true;
|
||||
let msg = '';
|
||||
const isPhone = /^0\d{2,3}-?\d{7,8}$/;
|
||||
|
||||
if (!validatenull(phone)) {
|
||||
if (phone.length === 11) {
|
||||
if (isPhone.test(phone)) {
|
||||
msg = '手机号码格式不正确';
|
||||
} else {
|
||||
result = false;
|
||||
}
|
||||
} else {
|
||||
msg = '手机号码长度不为11位';
|
||||
}
|
||||
} else {
|
||||
msg = '手机号码不能为空';
|
||||
}
|
||||
return [result, msg];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证姓名是否正确
|
||||
* @param name - 姓名
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function validatename(name: string): boolean {
|
||||
const regName = /^[\u4e00-\u9fa5]{2,4}$/;
|
||||
return regName.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证是否为整数
|
||||
* @param num - 数字
|
||||
* @param type - 类型 1:非数字 2:整数
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function validatenum(num: string, type: number): boolean {
|
||||
let regName = /[^\d.]/g;
|
||||
if (type === 1) {
|
||||
if (!regName.test(num)) return false;
|
||||
} else if (type === 2) {
|
||||
regName = /[^\d]/g;
|
||||
if (!regName.test(num)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证是否为小数
|
||||
* @param num - 数字
|
||||
* @param type - 类型
|
||||
* @returns 是否有效
|
||||
*/
|
||||
export function validatenumord(num: string, type: number): boolean {
|
||||
let regName = /[^\d.]/g;
|
||||
if (type === 1) {
|
||||
if (!regName.test(num)) return false;
|
||||
} else if (type === 2) {
|
||||
regName = /[^\d.]/g;
|
||||
if (!regName.test(num)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为空
|
||||
* @param val - 待验证的值
|
||||
* @returns 是否为空
|
||||
*/
|
||||
export function validatenull(val: any): boolean {
|
||||
if (typeof val === 'boolean') {
|
||||
return false;
|
||||
}
|
||||
if (typeof val === 'number') {
|
||||
return false;
|
||||
}
|
||||
if (val instanceof Array) {
|
||||
if (val.length === 0) return true;
|
||||
} else if (val instanceof Object) {
|
||||
if (JSON.stringify(val) === '{}') return true;
|
||||
} else {
|
||||
if (val === 'null' || val == null || val === 'undefined' || val === undefined || val === '') return true;
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断输入是否为有效的 JSON 或者 JSON 字符串
|
||||
* @param val - 待判断的值
|
||||
* @returns 如果是对象或可解析的 JSON 字符串,返回 true;否则返回 false
|
||||
*/
|
||||
export function validatejson(val: any): boolean {
|
||||
// 直接判断是否为对象(排除 null 和数组)
|
||||
if (val !== null && typeof val === 'object') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 尝试解析字符串为 JSON
|
||||
if (typeof val === 'string') {
|
||||
try {
|
||||
const obj = JSON.parse(val);
|
||||
// 解析后还需判断是否为对象或数组
|
||||
return obj !== null && typeof obj === 'object';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 非对象、非数组、非字符串,或者字符串不是 JSON
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user