This commit is contained in:
吴红兵
2025-12-02 10:37:49 +08:00
commit 1f645dad3e
1183 changed files with 147673 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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;
},
},
};

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 || []
}
}
}

View 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;
};

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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);
},
},
};

View 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;
}

View 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;
}

View 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>;

View 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;
};
};

View 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;
}
}

View 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;
}

View 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[];
}

View 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;
}

View 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';
}
}

View 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, '&lt;').replace(/>/g, '&gt;');
// 返回格式化的字符串,并添加样式类
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;
}
}
}

View 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);
};

View 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;
}