feat: 支持工作流插件复用与试运行

- 新增工作流插件类型、发布快照同步、实时可用性与下线影响检查

- 收口绑定候选、分类权限、间接环路校验与运行态优雅降级

- 补齐管理端工作流插件配置、详情与试运行界面及定向测试
This commit is contained in:
2026-04-12 13:15:13 +08:00
parent 6da90e2296
commit 47655a728b
57 changed files with 4018 additions and 780 deletions

View File

@@ -75,6 +75,9 @@
"submitDeleteApprovalConfirm": "Delete the current workflow?",
"offlineImpactBoundBotsIntro": "This workflow is currently bound to the following bots:",
"offlineImpactBoundBotsFooter": "After the workflow goes offline, the system will automatically remove it from these bots.",
"offlineImpactBoundPluginsIntro": "This workflow is currently bound to the following plugins:",
"offlineImpactBoundPluginsFooter": "After offline approval succeeds, these plugins will automatically become unavailable and show the reason in plugin management.",
"offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from bots and mark the related plugins as unavailable.",
"publishPendingHint": "There is already an approval in progress for this workflow.",
"deletePendingHint": "There is already an approval in progress for this workflow.",
"check": "Check",

View File

@@ -5,7 +5,11 @@
"name": "Name",
"description": "Description",
"type": "Type",
"typeHttp": "HTTP Plugin",
"typeWorkflow": "Workflow Plugin",
"baseUrl": "BaseUrl",
"workflowId": "Bound workflow",
"workflowTitle": "Workflow title",
"authType": "AuthType",
"created": "Created",
"icon": "Icon",
@@ -20,7 +24,8 @@
"placeholder": {
"name": "Please enter plugin name",
"description": "Please enter plugin description",
"categorize": "Please enter categorize"
"categorize": "Please enter categorize",
"workflow": "Please select a published workflow"
},
"button": {
"addPlugin": "Add Plugin",
@@ -29,5 +34,11 @@
},
"toolsManagement": "Tools Management",
"searchUsers": "Search Users",
"parameterValue": "ParameterValue"
"parameterValue": "ParameterValue",
"workflow": "Workflow",
"workflowPluginHint": "Workflow plugins mirror the published snapshot of the target workflow. Availability is evaluated in real time against workflow permissions and approval status.",
"workflowPluginUnavailable": "This workflow plugin is unavailable",
"workflowSnapshotSynced": "Published snapshot synced",
"reasonMessage": "Reason",
"onlyPublishedWorkflow": "Only published workflows that you can currently access are selectable."
}

View File

@@ -13,6 +13,7 @@
"debugStatus": "DebugStatus",
"englishName": "EnglishName",
"createPluginTool": "Create tool",
"systemManaged": "System synced",
"pluginToolEdit": {
"basicInfo": "Basic Info",
"configureInputParameters": "Configure input parameters",
@@ -21,11 +22,16 @@
"toolPath": "Tool path",
"requestMethod": "RequestMethod",
"runResult": "Run result",
"run": "run"
"run": "run",
"workflowTarget": "Target workflow",
"unavailableHint": "The bound workflow is currently unavailable, so execution will not be started.",
"runWorkflowStepsEmpty": "After starting a trial run, each node execution result will be shown here.",
"workflowStepsPending": "The trial run has started. Waiting for node execution details..."
},
"parameterName": "Name",
"parameterDescription": "Description",
"parameterType": "Type",
"direction": "Direction",
"inputMethod": "InputMethod",
"required": "Required",
"defaultValue": "DefaultValue",

View File

@@ -75,6 +75,9 @@
"submitDeleteApprovalConfirm": "确认删除当前工作流吗?",
"offlineImpactBoundBotsIntro": "当前工作流被以下聊天助手绑定:",
"offlineImpactBoundBotsFooter": "下线成功后,系统会自动从这些聊天助手中解绑该工作流。",
"offlineImpactBoundPluginsIntro": "当前工作流被以下插件绑定:",
"offlineImpactBoundPluginsFooter": "下线审批通过后,这些插件会自动变为不可用,并在插件页展示对应原因。",
"offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从聊天助手中解绑该工作流,同时让相关插件进入不可用状态。",
"publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。",
"check": "检查",

View File

@@ -5,7 +5,11 @@
"name": "名称",
"description": "描述",
"type": "类型",
"typeHttp": "HTTP 插件",
"typeWorkflow": "工作流插件",
"baseUrl": "基础URL",
"workflowId": "绑定工作流",
"workflowTitle": "工作流名称",
"authType": "认证方式",
"created": "创建时间",
"icon": "图标地址",
@@ -20,7 +24,8 @@
"placeholder": {
"name": "请输入插件名称",
"description": "请输入插件描述",
"categorize": "请选择分类"
"categorize": "请选择分类",
"workflow": "请选择已发布工作流"
},
"button": {
"addPlugin": "新增插件",
@@ -29,5 +34,11 @@
},
"toolsManagement": "工具管理",
"searchUsers": "搜索用户",
"parameterValue": "参数值"
"parameterValue": "参数值",
"workflow": "工作流",
"workflowPluginHint": "工作流插件会自动镜像目标工作流的已发布快照,插件可用性会实时跟随工作流权限和审批状态变化。",
"workflowPluginUnavailable": "当前工作流插件不可用",
"workflowSnapshotSynced": "已同步发布快照",
"reasonMessage": "不可用原因",
"onlyPublishedWorkflow": "仅支持选择已发布且当前可访问的工作流。"
}

View File

@@ -13,6 +13,7 @@
"debugStatus": "调试状态【0失败 1成功】",
"englishName": "英文名称",
"createPluginTool": "创建工具",
"systemManaged": "系统同步",
"pluginToolEdit": {
"basicInfo": "基本信息",
"configureInputParameters": "配置输入参数",
@@ -21,11 +22,16 @@
"toolPath": "工具路径",
"requestMethod": "请求方法",
"runResult": "运行结果",
"run": "运行"
"run": "运行",
"workflowTarget": "目标工作流",
"unavailableHint": "当前绑定工作流不可用,本次不会发起执行。",
"runWorkflowStepsEmpty": "开始试运行后,这里会展示每个节点的执行结果。",
"workflowStepsPending": "试运行已发起,正在等待节点执行信息..."
},
"parameterName": "参数名称",
"parameterDescription": "参数描述",
"parameterType": "参数类型",
"direction": "方向",
"inputMethod": "传入方法",
"required": "是否必填",
"defaultValue": "默认值",

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { Plus, Remove } from '@element-plus/icons-vue';
import {
ElAlert,
ElButton,
ElForm,
ElFormItem,
ElIcon,
@@ -22,15 +24,28 @@ import { api } from '#/api/request';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
const embeddingLlmList = ref<any>([]);
const rerankerLlmList = ref<any>([]);
const categoryList = ref<any[]>([]);
interface headersType {
interface HeaderItem {
label: string;
value: string;
}
const authTypeList = ref<headersType[]>([
interface WorkflowCandidate {
id: string;
title: string;
}
const emit = defineEmits(['reload']);
const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const btnLoading = ref(false);
const categoryList = ref<any[]>([]);
const workflowCandidates = ref<WorkflowCandidate[]>([]);
const tempAddHeaders = ref<HeaderItem[]>([]);
const entity = ref<any>(createDefaultEntity());
const authTypeList = [
{
label: 'None',
value: 'none',
@@ -39,79 +54,211 @@ const authTypeList = ref<headersType[]>([
label: 'Service token / ApiKey',
value: 'apiKey',
},
]);
onMounted(() => {
api.get('/api/v1/plugin/modelList?supportEmbed=true').then((res) => {
embeddingLlmList.value = res.data;
});
api
.get('/api/v1/plugin/modelList?supportRerankerLlmList=true')
.then((res) => {
rerankerLlmList.value = res.data;
});
api.get('/api/v1/pluginCategory/list').then((res) => {
if (res.errorCode === 0) {
categoryList.value = res.data;
}
});
});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const tempAddHeaders = ref<headersType[]>([]);
const entity = ref<any>({
alias: '',
categoryIds: [],
deptId: '',
icon: '',
title: '',
authType: 'none',
description: '',
englishName: '',
headers: '',
position: '',
});
const btnLoading = ref(false);
const rules = ref({
];
const pluginTypeOptions = [
{
label: $t('plugin.typeHttp'),
value: 1,
},
{
label: $t('plugin.typeWorkflow'),
value: 2,
},
];
const isWorkflowType = computed(
() => Number(entity.value.type || 1) === 2,
);
const rules = computed<FormRules>(() => ({
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
description: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
baseUrl: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (!isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'blur',
},
],
workflowId: [
{
validator: (_rule, value, callback) => {
if (isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
authType: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
tokenKey: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
tokenValue: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (!isWorkflowType.value && !value) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
position: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (
!isWorkflowType.value &&
entity.value.authType === 'apiKey' &&
!value
) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'change',
},
],
tokenKey: [
{
validator: (_rule, value, callback) => {
if (
!isWorkflowType.value &&
entity.value.authType === 'apiKey' &&
!value
) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'blur',
},
],
tokenValue: [
{
validator: (_rule, value, callback) => {
if (
!isWorkflowType.value &&
entity.value.authType === 'apiKey' &&
!value
) {
callback(new Error($t('message.required')));
return;
}
callback();
},
trigger: 'blur',
},
],
}));
watch(
() => entity.value.type,
(type) => {
if (Number(type || 1) === 2) {
entity.value.baseUrl = '';
entity.value.authType = 'none';
entity.value.position = '';
entity.value.tokenKey = '';
entity.value.tokenValue = '';
tempAddHeaders.value = [];
return;
}
entity.value.workflowId = '';
entity.value.workflowTitle = '';
entity.value.available = true;
entity.value.reasonMessage = '';
},
);
onMounted(async () => {
await Promise.all([loadCategories(), loadWorkflowCandidates()]);
});
// functions
function openDialog(row: any) {
tempAddHeaders.value = [];
if (row.id) {
isAdd.value = false;
if (row.headers) {
tempAddHeaders.value = JSON.parse(row.headers);
}
defineExpose({
openDialog,
});
function createDefaultEntity() {
return {
alias: '',
authType: 'none',
available: true,
baseUrl: '',
categoryIds: [],
deptId: '',
description: '',
englishName: '',
headers: '',
icon: '',
name: '',
pluginType: 1,
position: '',
reasonMessage: '',
title: '',
tokenKey: '',
tokenValue: '',
type: 1,
workflowId: '',
workflowTitle: '',
};
}
async function loadCategories() {
const res = await api.get('/api/v1/pluginCategory/list');
if (res.errorCode === 0) {
categoryList.value = res.data;
}
}
async function loadWorkflowCandidates(keyword: string = '') {
const res = await api.get('/api/v1/plugin/workflowCandidates', {
params: {
keyword,
},
});
if (res.errorCode === 0) {
workflowCandidates.value = Array.isArray(res.data) ? res.data : [];
ensureCurrentWorkflowOption();
}
}
function ensureCurrentWorkflowOption() {
if (!entity.value.workflowId || !entity.value.workflowTitle) {
return;
}
const exists = workflowCandidates.value.some(
(item) => String(item.id) === String(entity.value.workflowId),
);
if (!exists) {
workflowCandidates.value.unshift({
id: entity.value.workflowId,
title: entity.value.workflowTitle,
});
}
}
function openDialog(row: any) {
tempAddHeaders.value = row.headers ? JSON.parse(row.headers) : [];
isAdd.value = !row.id;
entity.value = {
...createDefaultEntity(),
...row,
authType: row.authType || 'none',
categoryIds: row.categoryIds?.map((item: any) => item.id) || [],
type: Number(row.type || row.pluginType || 1),
};
ensureCurrentWorkflowOption();
dialogVisible.value = true;
}
@@ -131,83 +278,86 @@ async function syncPluginCategories(pluginId: string, categoryIds: string[]) {
}
}
function normalizePayload() {
const plainEntity = { ...entity.value };
const categoryIds = [...(plainEntity.categoryIds || [])];
delete plainEntity.categoryIds;
if (isWorkflowType.value) {
plainEntity.baseUrl = '';
plainEntity.headers = [];
plainEntity.authType = 'none';
plainEntity.position = '';
plainEntity.tokenKey = '';
plainEntity.tokenValue = '';
}
return {
payload: {
...plainEntity,
headers: isWorkflowType.value ? [] : [...tempAddHeaders.value],
},
categoryIds,
};
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
const plainEntity = { ...entity.value };
const plainHeaders = [...tempAddHeaders.value];
const categoryIds = [...(plainEntity.categoryIds || [])];
delete plainEntity.categoryIds;
if (isAdd.value) {
api
.post('/api/v1/plugin/plugin/save', {
...plainEntity,
headers: plainHeaders,
})
.then(async (res) => {
if (res.errorCode === 0) {
const pluginId =
res.data?.id || plainEntity.id || entity.value.id;
if (!pluginId) {
throw new Error('插件保存成功但未返回插件ID');
}
await syncPluginCategories(pluginId, categoryIds);
dialogVisible.value = false;
ElMessage.success($t('message.saveOkMessage'));
emit('reload');
} else {
ElMessage.error(res.message);
}
})
.catch((error) => {
ElMessage.error(error?.message || $t('message.saveFailMessage'));
})
.finally(() => {
btnLoading.value = false;
});
} else {
api
.post('/api/v1/plugin/plugin/update', {
...plainEntity,
headers: plainHeaders,
})
.then(async (res) => {
if (res.errorCode === 0) {
await syncPluginCategories(entity.value.id, categoryIds);
dialogVisible.value = false;
ElMessage.success($t('message.updateOkMessage'));
emit('reload');
} else {
ElMessage.error(res.message);
}
})
.catch((error) => {
ElMessage.error(error?.message || $t('message.saveFailMessage'));
})
.finally(() => {
btnLoading.value = false;
});
saveForm.value?.validate(async (valid) => {
if (!valid) {
return;
}
btnLoading.value = true;
const { payload, categoryIds } = normalizePayload();
const requestUrl = isAdd.value
? '/api/v1/plugin/plugin/save'
: '/api/v1/plugin/plugin/update';
try {
const res = await api.post(requestUrl, payload);
if (res.errorCode !== 0) {
ElMessage.error(res.message);
return;
}
const pluginId = res.data?.id || payload.id || entity.value.id;
if (!pluginId) {
throw new Error('插件保存成功但未返回插件ID');
}
await syncPluginCategories(pluginId, categoryIds);
dialogVisible.value = false;
ElMessage.success(
isAdd.value ? $t('message.saveOkMessage') : $t('message.updateOkMessage'),
);
emit('reload');
} catch (error: any) {
ElMessage.error(error?.message || $t('message.saveFailMessage'));
} finally {
btnLoading.value = false;
}
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
tempAddHeaders.value = [];
entity.value = {};
entity.value = createDefaultEntity();
dialogVisible.value = false;
}
function addHeader() {
tempAddHeaders.value.push({
label: '',
value: '',
});
}
function removeHeader(index: number) {
tempAddHeaders.value.splice(index, 1);
}
function handleWorkflowChange(value: string) {
const target = workflowCandidates.value.find(
(item) => String(item.id) === String(value),
);
entity.value.workflowTitle = target?.title || '';
}
</script>
<template>
@@ -237,15 +387,22 @@ function removeHeader(index: number) {
>
<UploadAvatar v-model="entity.icon" />
</ElFormItem>
<ElFormItem prop="type" :label="$t('plugin.type')">
<ElSelect v-model="entity.type" :disabled="!isAdd">
<ElOption
v-for="item in pluginTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="name" :label="$t('plugin.name')">
<ElInput
v-model.trim="entity.name"
:placeholder="$t('plugin.placeholder.name')"
/>
</ElFormItem>
<ElFormItem prop="baseUrl" :label="$t('plugin.baseUrl')">
<ElInput v-model.trim="entity.baseUrl" />
</ElFormItem>
<ElFormItem prop="description" :label="$t('plugin.description')">
<ElInput
v-model.trim="entity.description"
@@ -270,61 +427,108 @@ function removeHeader(index: number) {
/>
</ElSelect>
</ElFormItem>
<ElFormItem prop="Headers" label="Headers">
<div
class="headers-container-reduce flex flex-row gap-4"
v-for="(item, index) in tempAddHeaders"
:key="index"
>
<div class="head-con-content flex flex-row gap-4">
<ElInput v-model.trim="item.label" placeholder="header name" />
<ElInput v-model.trim="item.value" placeholder="header value" />
<ElIcon size="20" @click="removeHeader" style="cursor: pointer">
<Remove />
</ElIcon>
<template v-if="isWorkflowType">
<ElAlert
type="info"
:closable="false"
show-icon
class="mb-4"
:title="$t('plugin.workflowPluginHint')"
/>
<ElFormItem prop="workflowId" :label="$t('plugin.workflowId')">
<ElSelect
v-model="entity.workflowId"
filterable
remote
reserve-keyword
:remote-method="loadWorkflowCandidates"
:placeholder="$t('plugin.placeholder.workflow')"
@change="handleWorkflowChange"
>
<ElOption
v-for="item in workflowCandidates"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</ElSelect>
<div class="form-helper-text">
{{ $t('plugin.onlyPublishedWorkflow') }}
</div>
</div>
<ElButton @click="addHeader" class="addHeadersBtn">
<ElIcon size="18" style="margin-right: 4px">
<Plus />
</ElIcon>
{{ $t('button.add') }}headers
</ElButton>
</ElFormItem>
<ElFormItem prop="authType" :label="$t('plugin.authType')">
<ElSelect v-model="entity.authType">
<ElOption
v-for="item in authTypeList"
:key="item.value"
:label="item.label"
:value="item.value || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
prop="position"
:label="$t('plugin.position')"
v-if="entity.authType === 'apiKey'"
>
<ElRadioGroup v-model="entity.position">
<ElRadio value="headers">headers</ElRadio>
<ElRadio value="query">query</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
prop="tokenKey"
:label="$t('plugin.tokenKey')"
v-if="entity.authType === 'apiKey'"
>
<ElInput v-model.trim="entity.tokenKey" />
</ElFormItem>
<ElFormItem
prop="tokenValue"
:label="$t('plugin.tokenValue')"
v-if="entity.authType === 'apiKey'"
>
<ElInput v-model.trim="entity.tokenValue" />
</ElFormItem>
</ElFormItem>
<ElAlert
v-if="!entity.available && entity.reasonMessage"
type="warning"
:closable="false"
show-icon
:title="$t('plugin.workflowPluginUnavailable')"
:description="entity.reasonMessage"
/>
</template>
<template v-else>
<ElFormItem prop="baseUrl" :label="$t('plugin.baseUrl')">
<ElInput v-model.trim="entity.baseUrl" />
</ElFormItem>
<ElFormItem prop="Headers" label="Headers">
<div
v-for="(item, index) in tempAddHeaders"
:key="index"
class="headers-container-reduce flex flex-row gap-4"
>
<div class="head-con-content flex flex-row gap-4">
<ElInput v-model.trim="item.label" placeholder="header name" />
<ElInput v-model.trim="item.value" placeholder="header value" />
<ElIcon
size="20"
style="cursor: pointer"
@click="removeHeader(index)"
>
<Remove />
</ElIcon>
</div>
</div>
<ElButton class="addHeadersBtn" @click="addHeader">
<ElIcon size="18" style="margin-right: 4px">
<Plus />
</ElIcon>
{{ $t('button.add') }}headers
</ElButton>
</ElFormItem>
<ElFormItem prop="authType" :label="$t('plugin.authType')">
<ElSelect v-model="entity.authType">
<ElOption
v-for="item in authTypeList"
:key="item.value"
:label="item.label"
:value="item.value || ''"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="entity.authType === 'apiKey'"
prop="position"
:label="$t('plugin.position')"
>
<ElRadioGroup v-model="entity.position">
<ElRadio value="headers">headers</ElRadio>
<ElRadio value="query">query</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="entity.authType === 'apiKey'"
prop="tokenKey"
:label="$t('plugin.tokenKey')"
>
<ElInput v-model.trim="entity.tokenKey" />
</ElFormItem>
<ElFormItem
v-if="entity.authType === 'apiKey'"
prop="tokenValue"
:label="$t('plugin.tokenValue')"
>
<ElInput v-model.trim="entity.tokenValue" />
</ElFormItem>
</template>
</ElForm>
</EasyFlowFormModal>
</template>
@@ -346,4 +550,11 @@ function removeHeader(index: number) {
align-items: center;
margin-bottom: 8px;
}
.form-helper-text {
margin-top: 6px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -17,6 +17,7 @@ import {
ElInput,
ElMessage,
ElMessageBox,
ElTag,
} from 'element-plus';
import { api } from '#/api/request';
@@ -94,6 +95,10 @@ const actions: ActionButton[] = [
},
},
];
const pluginTypeTagMap = {
1: $t('plugin.typeHttp'),
2: $t('plugin.typeWorkflow'),
};
const categoryList = ref<PluginCategory[]>([]);
const controlBtns = [
{
@@ -265,11 +270,26 @@ const handleClickCategory = (item: PluginCategory) => {
title-field="title"
icon-field="icon"
desc-field="description"
tag-field="type"
:tag-map="pluginTypeTagMap"
:data="pageList"
:primary-action="primaryAction"
:actions="actions"
:default-icon="defaultPluginIcon"
/>
>
<template #corner="{ item }">
<ElTag
v-if="item.type === 2 && item.available === false"
type="danger"
effect="light"
round
>
{{
item.reasonMessage || $t('plugin.workflowPluginUnavailable')
}}
</ElTag>
</template>
</CardPage>
</template>
</PageData>
</div>

View File

@@ -9,12 +9,14 @@ const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
editable: false,
isEditOutput: false,
payloadMode: 'plugin',
});
const emit = defineEmits<Emits>();
export interface TreeTableNode {
key: string;
id?: string;
name: string;
description: string;
method?: 'Body' | 'Header' | 'Path' | 'Query';
@@ -29,6 +31,7 @@ interface Props {
modelValue?: TreeTableNode[];
editable?: boolean;
isEditOutput?: boolean;
payloadMode?: 'plugin' | 'workflow';
}
const data = ref<TreeTableNode[]>([]);
@@ -48,8 +51,12 @@ watch(
);
// 计算缩进宽度
const getNodeKey = (record?: Partial<TreeTableNode>): string => {
return String(record?.key || record?.id || '');
};
const getIndentWidth = (record: TreeTableNode): number => {
const level = String(record.key).split('-').length - 1;
const level = getNodeKey(record).split('-').length - 1;
const indentSize = 20;
return level > 0 ? level * indentSize : 0;
};
@@ -65,7 +72,7 @@ const onExpand = (_row: TreeTableNode, expandedRows: TreeTableNode[]) => {
};
// 验证字段
const validateFields = (): boolean => {
const validatePluginFields = (): boolean => {
const newErrors: Record<
string,
Partial<Record<keyof TreeTableNode, string>>
@@ -75,6 +82,7 @@ const validateFields = (): boolean => {
const checkNode = (node: TreeTableNode): boolean => {
const { name, description, method, type } = node;
const nodeErrors: Partial<Record<keyof TreeTableNode, string>> = {};
const nodeKey = getNodeKey(node);
if (!name?.trim()) {
nodeErrors.name = $t('message.cannotBeEmpty.name');
@@ -96,8 +104,8 @@ const validateFields = (): boolean => {
isValid = false;
}
if (Object.keys(nodeErrors).length > 0) {
newErrors[node.key] = nodeErrors;
if (nodeKey && Object.keys(nodeErrors).length > 0) {
newErrors[nodeKey] = nodeErrors;
}
if (node.children) {
@@ -117,17 +125,118 @@ const validateFields = (): boolean => {
return isValid;
};
const isBlankValue = (value: unknown): boolean => {
if (value === null || value === undefined) {
return true;
}
if (typeof value === 'string') {
return value.trim().length === 0;
}
if (Array.isArray(value)) {
return value.length === 0;
}
if (typeof value === 'object') {
return Object.keys(value as Record<string, unknown>).length === 0;
}
return false;
};
const normalizeNodeType = (node: TreeTableNode): string => {
return String(node.type || '').trim();
};
const parseWorkflowNodeValue = (node: TreeTableNode): any => {
const type = normalizeNodeType(node);
if (node.children?.length) {
if (type === 'Object') {
return buildWorkflowPayload(node.children);
}
if (type.includes('Array')) {
return parseArrayValue(node);
}
}
if (type.includes('Array')) {
return parseArrayValue(node);
}
return node.defaultValue;
};
const parseArrayValue = (node: TreeTableNode): any[] => {
if (Array.isArray(node.defaultValue as any)) {
return node.defaultValue as any[];
}
const raw = String(node.defaultValue || '').trim();
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return [raw];
}
};
const buildWorkflowPayload = (nodes: TreeTableNode[]): Record<string, any> => {
const payload: Record<string, any> = {};
for (const node of nodes) {
if (!node?.name) {
continue;
}
payload[node.name] = parseWorkflowNodeValue(node);
}
return payload;
};
const validateWorkflowFields = (): boolean => {
const newErrors: Record<
string,
Partial<Record<keyof TreeTableNode, string>>
> = {};
let isValid = true;
const checkNode = (node: TreeTableNode) => {
const nodeKey = getNodeKey(node);
const nodeErrors: Partial<Record<keyof TreeTableNode, string>> = {};
const value = parseWorkflowNodeValue(node);
if (node.required && isBlankValue(value)) {
nodeErrors.defaultValue = $t('message.required');
isValid = false;
}
if (nodeKey && Object.keys(nodeErrors).length > 0) {
newErrors[nodeKey] = nodeErrors;
}
node.children?.forEach(checkNode);
};
data.value.forEach((node) => {
checkNode(node);
});
errors.value = newErrors;
return isValid;
};
// 判断是否为根节点
const isRootNode = (record: TreeTableNode): boolean => {
return !record.key.includes('-');
return !getNodeKey(record).includes('-');
};
// 提交参数
const handleSubmitParams = () => {
if (!validateFields()) {
const valid =
props.payloadMode === 'workflow'
? validateWorkflowFields()
: validatePluginFields();
if (valid !== true) {
ElMessage.error($t('message.completeForm'));
return;
}
if (props.payloadMode === 'workflow') {
return buildWorkflowPayload(data.value);
}
return data.value;
};
@@ -145,7 +254,7 @@ interface Emits {
<div class="tree-table-container">
<ElTable
:data="data"
row-key="key"
:row-key="getNodeKey"
:border="true"
size="default"
:expand-row-keys="expandedKeys"
@@ -160,7 +269,7 @@ interface Emits {
<template #default="{ row }">
<div class="name-cell">
<div
v-if="!props.editable"
v-if="!props.editable || props.payloadMode === 'workflow'"
:style="{ paddingLeft: `${getIndentWidth(row)}px` }"
>
{{ row.name || '' }}
@@ -175,11 +284,11 @@ interface Emits {
/>
</div>
<div
v-if="errors[row.key]?.name"
v-if="errors[getNodeKey(row)]?.name"
class="error-message"
:style="{ marginLeft: `${getIndentWidth(row)}px` }"
>
{{ errors[row.key]?.name }}
{{ errors[getNodeKey(row)]?.name }}
</div>
</div>
</div>
@@ -195,12 +304,19 @@ interface Emits {
<template #default="{ row }">
<span v-if="row.type === 'Object'"></span>
<span v-else-if="!props.editable">{{ row.defaultValue || '' }}</span>
<ElInput
v-else
v-model="row.defaultValue"
@input="handleDataChange"
:disabled="!props.editable"
/>
<div v-else class="value-input-wrapper">
<ElInput
v-model="row.defaultValue"
@input="handleDataChange"
:disabled="!props.editable"
/>
<div
v-if="errors[getNodeKey(row)]?.defaultValue"
class="error-message"
>
{{ errors[getNodeKey(row)]?.defaultValue }}
</div>
</div>
</template>
</ElTableColumn>
</ElTable>
@@ -243,6 +359,12 @@ interface Emits {
color: #ff4d4f;
}
.value-input-wrapper {
display: flex;
flex-direction: column;
gap: 2px;
}
.action-buttons .el-button {
display: flex;
align-items: center;

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { nextTick, onUnmounted, ref, watch } from 'vue';
import { EasyFlowPanelModal } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
import { preferences } from '@easyflow/preferences';
import { sortNodes } from '@easyflow/utils';
import { VideoPlay } from '@element-plus/icons-vue';
import { ElButton, ElMenu, ElMenuItem } from 'element-plus';
import { ElAlert, ElButton, ElMenu, ElMenuItem, ElMessage } from 'element-plus';
import { JsonViewer } from 'vue3-json-viewer';
import { api } from '#/api/request';
import PluginRunParams from '#/views/ai/plugin/PluginRunParams.vue';
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
@@ -26,42 +28,159 @@ watch(
},
);
const dialogVisible = ref(false);
const openDialog = () => {
getPluginToolInfo();
runResultResponse.value = null;
dialogVisible.value = true;
};
const runTitle = ref('');
const runResult = ref('');
const inputDataParams = ref<any>(null);
const runResultResponse = ref<any>(null);
function getPluginToolInfo() {
api
.post('/api/v1/pluginItem/tool/search', {
aiPluginToolId: props.pluginToolId,
})
.then((res) => {
if (res.errorCode === 0) {
runTitle.value = `${res.data.aiPlugin.title} - ${res.data.data.name} ${$t(
'pluginItem.inputData',
)}`;
runResult.value = `${$t('pluginItem.pluginToolEdit.runResult')}`;
inputDataParams.value = JSON.parse(res.data.data.inputData || '[]');
}
});
}
const runParamsRef = ref();
const runLoading = ref(false);
const pluginAvailable = ref(true);
const pluginReasonMessage = ref('');
const isWorkflowPlugin = ref(false);
const workflowId = ref<null | string>(null);
const workflowSnapshot = ref<any>(null);
const workflowNodeJson = ref<any[]>([]);
const pollingNodes = ref<any[]>([]);
const executeId = ref('');
const pollingData = ref<any>({ nodes: {} });
const initSignal = ref(false);
const pollingTimer = ref<null | ReturnType<typeof setInterval>>(null);
const activeIndex = ref('1');
const dialogContentKey = ref(0);
const dialogPreparing = ref(false);
defineExpose({
openDialog,
});
function handleSelect(index: string) {
activeIndex.value = index;
async function openDialog() {
if (dialogPreparing.value) {
return;
}
dialogPreparing.value = true;
resetDialogState();
const ready = await getPluginToolInfo();
dialogPreparing.value = false;
if (!ready) {
return;
}
dialogContentKey.value += 1;
await nextTick();
dialogVisible.value = true;
}
const runParamsRef = ref();
const runLoading = ref(false);
function resetExecutionState() {
stopPolling();
runResultResponse.value = null;
runLoading.value = false;
executeId.value = '';
pollingData.value = { nodes: {} };
initSignal.value = !initSignal.value;
activeIndex.value = '1';
}
function resetDialogState() {
resetExecutionState();
runTitle.value = '';
runResult.value = '';
inputDataParams.value = [];
pluginAvailable.value = true;
pluginReasonMessage.value = '';
isWorkflowPlugin.value = false;
workflowId.value = null;
workflowSnapshot.value = null;
workflowNodeJson.value = [];
pollingNodes.value = [];
}
async function getPluginToolInfo() {
try {
const res = await api.post('/api/v1/pluginItem/tool/search', {
aiPluginToolId: props.pluginToolId,
});
if (res.errorCode !== 0 || !res.data) {
ElMessage.error(res?.message || '加载试运行信息失败');
return false;
}
runTitle.value = `${res.data.aiPlugin.title} - ${res.data.data.name} ${$t(
'pluginItem.inputData',
)}`;
runResult.value = `${$t('pluginItem.pluginToolEdit.runResult')}`;
inputDataParams.value = JSON.parse(res.data.data.inputData || '[]');
pluginAvailable.value = res.data.aiPlugin?.available !== false;
pluginReasonMessage.value = res.data.aiPlugin?.reasonMessage || '';
isWorkflowPlugin.value = Number(res.data.aiPlugin?.type || 1) === 2;
workflowId.value = res.data.aiPlugin?.workflowId
? String(res.data.aiPlugin.workflowId)
: null;
workflowSnapshot.value = res.data.workflowSnapshot || null;
hydrateWorkflowNodes(workflowSnapshot.value);
return true;
} catch (error) {
ElMessage.error(buildErrorResult(error).error);
return false;
}
}
function hydrateWorkflowNodes(snapshot: any) {
workflowNodeJson.value = [];
pollingNodes.value = [];
const content = snapshot?.content;
if (!content) {
return;
}
try {
const workflowContent = JSON.parse(content);
workflowNodeJson.value = sortNodes(workflowContent) || [];
pollingNodes.value = Array.isArray(workflowContent?.nodes)
? workflowContent.nodes.map((node: any) => ({
nodeId: node.id,
nodeName: node?.data?.title || node.id,
}))
: [];
} catch (error) {
console.error('解析工作流插件快照失败', error);
}
}
function buildErrorResult(error: any) {
const responseData = error?.response?.data ?? {};
return {
error:
responseData?.error ||
responseData?.message ||
error?.message ||
'试运行失败',
};
}
function buildUnavailableResult() {
runResultResponse.value = {
skipped: true,
reasonMessage:
pluginReasonMessage.value ||
$t('pluginItem.pluginToolEdit.unavailableHint'),
};
}
function handleSubmitRun() {
if (!pluginAvailable.value) {
buildUnavailableResult();
return;
}
const runParams = runParamsRef.value?.handleSubmitParams?.();
if (runParams === null || runParams === undefined) {
return;
}
if (isWorkflowPlugin.value) {
handleWorkflowSubmit(runParams);
return;
}
handleHttpSubmit(runParams);
}
function handleHttpSubmit(runParams: any) {
runLoading.value = true;
const runParams = runParamsRef.value.handleSubmitParams();
api
.post('/api/v1/pluginItem/test', {
pluginToolId: props.pluginToolId,
@@ -72,9 +191,160 @@ function handleSubmitRun() {
runResultResponse.value = res.data;
activeIndex.value = '2';
}
})
.catch((error) => {
runResultResponse.value = buildErrorResult(error);
})
.finally(() => {
runLoading.value = false;
});
}
function handleWorkflowSubmit(runParams: any) {
if (!workflowId.value) {
runResultResponse.value = {
error: '当前插件未绑定有效工作流,无法试运行。',
};
return;
}
resetExecutionState();
runLoading.value = true;
api
.post('/api/v1/pluginItem/testAsync', {
pluginToolId: props.pluginToolId,
inputData: JSON.stringify(runParams),
})
.then((res) => {
if (res.errorCode === 0 && res.data) {
executeId.value = String(res.data);
pollingData.value = {
executeId: executeId.value,
status: 1,
nodes: {},
};
runResultResponse.value = pollingData.value;
activeIndex.value = '2';
startPolling(executeId.value);
}
})
.catch((error) => {
runResultResponse.value = buildErrorResult(error);
})
.finally(() => {
runLoading.value = false;
});
}
function startPolling(nextExecuteId: string) {
stopPolling();
pollingTimer.value = setInterval(() => {
executePolling(nextExecuteId);
}, 1000);
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
}
function executePolling(nextExecuteId: string) {
api
.post('/api/v1/pluginItem/testChainStatus', {
executeId: nextExecuteId,
nodes: pollingNodes.value,
})
.then((res) => {
if (res.errorCode !== 0) {
return;
}
const nextData = {
...res.data,
nodes: res.data?.nodes || {},
};
pollingData.value = nextData;
runResultResponse.value = nextData;
if (nextData.status !== 1) {
stopPolling();
}
})
.catch((error) => {
stopPolling();
runResultResponse.value = buildErrorResult(error);
});
}
function resumeChain(payload: any) {
if (!executeId.value) {
return;
}
api
.post('/api/v1/pluginItem/testResume', {
executeId: executeId.value,
confirmParams: payload?.confirmParams || {},
})
.then((res) => {
if (res.errorCode === 0) {
startPolling(executeId.value);
}
})
.catch((error) => {
runResultResponse.value = buildErrorResult(error);
});
}
function showWorkflowSteps() {
return isWorkflowPlugin.value === true;
}
function showWorkflowStepList() {
return showWorkflowSteps();
}
function showWorkflowResultAlert() {
return (
showWorkflowSteps() &&
(!!runResultResponse.value?.error || !!runResultResponse.value?.skipped)
);
}
function workflowResultAlertType() {
return runResultResponse.value?.skipped ? 'warning' : 'error';
}
function workflowResultAlertMessage() {
return (
runResultResponse.value?.reasonMessage ||
runResultResponse.value?.error ||
''
);
}
function showWorkflowStepsEmpty() {
return showWorkflowSteps() && workflowNodeJson.value.length === 0;
}
function showHttpResultEmpty() {
return (
showWorkflowSteps() !== true &&
activeIndex.value === '2' &&
!runResultResponse.value
);
}
function handleSelect(index: string) {
activeIndex.value = index;
}
function closeDialog() {
stopPolling();
dialogVisible.value = false;
}
onUnmounted(() => {
stopPolling();
});
</script>
<template>
@@ -83,27 +353,37 @@ function handleSubmitRun() {
width="80%"
align-center
:title="$t('pluginItem.pluginToolEdit.trialRun')"
:before-close="() => (dialogVisible = false)"
:before-close="closeDialog"
>
<div class="run-test-container">
<div :key="dialogContentKey" class="run-test-container">
<div class="run-test-params">
<div class="run-title-style">
{{ runTitle }}
</div>
<ElAlert
v-if="!pluginAvailable"
class="mb-4"
type="warning"
:closable="false"
show-icon
:title="$t('pluginItem.pluginToolEdit.unavailableHint')"
:description="pluginReasonMessage"
/>
<div>
<PluginRunParams
v-model="inputDataParams"
:editable="true"
:is-edit-output="true"
ref="runParamsRef"
v-model="inputDataParams"
:editable="pluginAvailable"
:is-edit-output="true"
:payload-mode="isWorkflowPlugin ? 'workflow' : 'plugin'"
/>
</div>
</div>
<div class="run-test-result">
<div class="run-title-style">
{{ runResult }}
{{ showWorkflowSteps() ? $t('aiWorkflow.steps') : runResult }}
</div>
<div>
<div v-if="!showWorkflowSteps()">
<ElMenu
:default-active="activeIndex"
class="el-menu-demo"
@@ -116,32 +396,58 @@ function handleSubmitRun() {
</ElMenu>
</div>
<div class="run-res-json">
<JsonViewer
v-if="activeIndex === '1'"
:value="inputDataParams || {}"
copyable
:expand-depth="Infinity"
:theme="themeMode"
/>
<JsonViewer
v-if="activeIndex === '2'"
:value="runResultResponse || {}"
copyable
:expand-depth="Infinity"
:theme="themeMode"
/>
<template v-if="showWorkflowSteps()">
<ElAlert
v-if="showWorkflowResultAlert()"
class="run-result-alert"
:type="workflowResultAlertType()"
:closable="false"
show-icon
:title="workflowResultAlertMessage()"
/>
<WorkflowSteps
v-if="showWorkflowStepList()"
:workflow-id="workflowId"
:node-json="workflowNodeJson"
:init-signal="initSignal"
:polling-data="pollingData"
@resume="resumeChain"
/>
<div v-if="showWorkflowStepsEmpty()" class="run-result-placeholder">
{{ $t('pluginItem.pluginToolEdit.runWorkflowStepsEmpty') }}
</div>
</template>
<template v-else>
<JsonViewer
v-if="activeIndex === '1'"
:value="inputDataParams || {}"
copyable
:expand-depth="Infinity"
:theme="themeMode"
/>
<JsonViewer
v-if="activeIndex === '2' && runResultResponse"
:value="runResultResponse || {}"
copyable
:expand-depth="Infinity"
:theme="themeMode"
/>
<div v-if="showHttpResultEmpty()" class="run-result-placeholder">
{{ $t('common.noDataAvailable') }}
</div>
</template>
</div>
</div>
</div>
<template #footer>
<ElButton @click="dialogVisible = false">
<ElButton @click="closeDialog">
{{ $t('button.cancel') }}
</ElButton>
<ElButton
type="primary"
:icon="VideoPlay"
@click="handleSubmitRun"
:loading="runLoading"
@click="handleSubmitRun"
>
{{ $t('pluginItem.pluginToolEdit.run') }}
</ElButton>
@@ -163,21 +469,34 @@ function handleSubmitRun() {
overflow: auto;
}
.run-res-json {
flex: 1;
width: 100%;
overflow: auto;
}
.run-test-result {
display: flex;
flex: 1;
flex-direction: column;
}
.name-cell {
position: relative;
min-width: 100%;
.run-res-json {
flex: 1;
width: 100%;
overflow: auto;
}
.run-result-alert {
margin-bottom: 16px;
}
.run-result-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
padding: 24px;
color: var(--el-text-color-secondary);
font-size: 14px;
text-align: center;
border: 1px dashed var(--el-border-color);
border-radius: 10px;
background: var(--el-fill-color-extra-light);
}
.run-title-style {
@@ -186,30 +505,6 @@ function handleSubmitRun() {
font-weight: bold;
}
.editable-name {
display: flex;
flex-direction: column;
gap: 2px;
}
.name-input-wrapper {
display: flex;
align-items: center;
width: 100%;
}
.name-input-wrapper .el-input {
box-sizing: border-box;
width: 100%;
}
.error-message {
margin-top: 2px;
font-size: 12px;
line-height: 1.2;
color: #ff4d4f;
}
:deep(.el-table td.el-table__cell.first-column div) {
display: flex;
gap: 2px;

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
@@ -14,23 +14,19 @@ import {
ElMessage,
ElOption,
ElSelect,
ElTable,
ElTableColumn,
} from 'element-plus';
import { api } from '#/api/request';
import PluginInputAndOutParams from '#/views/ai/plugin/PluginInputAndOutParams.vue';
import PluginRunTestModal from '#/views/ai/plugin/PluginRunTestModal.vue';
import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue';
const route = useRoute();
const router = useRouter();
const toolId = ref<string>((route.query.id as string) || '');
onMounted(() => {
if (!toolId.value) {
return;
}
getPluginToolInfo();
});
const pluginToolInfo = ref<any>({
name: '',
englishName: '',
@@ -41,23 +37,12 @@ const pluginToolInfo = ref<any>({
const pluginInfo = ref<any>({});
const pluginInputData = ref<any[]>([]);
const pluginOutputData = ref<any[]>([]);
function getPluginToolInfo() {
api
.post('/api/v1/pluginItem/tool/search', {
aiPluginToolId: toolId.value,
})
.then((res) => {
if (res.errorCode === 0) {
pluginToolInfo.value = res.data.data;
pluginInfo.value = res.data.aiPlugin;
pluginInputData.value = JSON.parse(res.data.data.inputData || '[]');
pluginOutputData.value = JSON.parse(res.data.data.outputData || '[]');
}
});
}
const pluginInputParamsEditable = ref(false);
const pluginOutputParamsEditable = ref(false);
const isWorkflowPlugin = ref(false);
const pluginUnavailable = ref(false);
const pluginUnavailableReason = ref('');
const workflowSnapshot = ref<any>(null);
const pluginBasicCollapse = ref({
title: $t('pluginItem.pluginToolEdit.basicInfo'),
@@ -74,33 +59,12 @@ const pluginBasicCollapseOutputParams = ref({
isOpen: false,
isEdit: false,
});
const pluginInputParamsRef = ref();
const pluginOutputParamsRef = ref();
const handleClickHeader = (index: number) => {
switch (index) {
case 1: {
pluginBasicCollapse.value.isOpen = !pluginBasicCollapse.value.isOpen;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isOpen =
!pluginBasicCollapseInputParams.value.isOpen;
const saveForm = ref();
const runTestRef = ref();
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isOpen =
!pluginBasicCollapseOutputParams.value.isOpen;
break;
}
// No default
}
};
const back = () => {
router.back();
};
const rules = reactive({
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
requestMethod: [
@@ -125,118 +89,7 @@ const rules = reactive({
},
],
});
const saveForm = ref();
const updatePluginTool = (index: number) => {
if (index === 1) {
if (!saveForm.value) return;
saveForm.value.validate((valid: boolean) => {
if (valid) {
updatePluginToolInfo(index);
}
});
} else {
updatePluginToolInfo(index);
}
};
const updatePluginToolInfo = (index: number) => {
api
.post('/api/v1/pluginItem/tool/update', {
id: toolId.value,
name: pluginToolInfo.value.name,
englishName: pluginToolInfo.value.englishName,
description: pluginToolInfo.value.description,
basePath: pluginToolInfo.value.basePath,
requestMethod: pluginToolInfo.value.requestMethod,
inputData: JSON.stringify(pluginInputData.value),
outputData: JSON.stringify(pluginOutputData.value),
})
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.updateOkMessage'));
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
break;
}
// No default
}
}
});
};
const handleEdit = (index: number) => {
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = true;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = true;
pluginBasicCollapseInputParams.value.isOpen = true;
pluginInputParamsEditable.value = true;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = true;
pluginBasicCollapseOutputParams.value.isOpen = true;
pluginOutputParamsEditable.value = true;
break;
}
// No default
}
};
const handleSave = (index: number) => {
if (index === 2) {
try {
// 调用校验方法,若抛异常则进入 catch
pluginInputParamsRef.value.handleSubmitParams();
} catch (error) {
console.error('校验失败:', error);
return;
}
}
if (index === 3) {
try {
pluginOutputParamsRef.value.handleSubmitParams();
} catch (error) {
console.error('校验失败:', error);
return;
}
}
pluginInputParamsEditable.value = false;
updatePluginTool(index);
};
const handleCancel = (index: number) => {
getPluginToolInfo();
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
pluginInputParamsEditable.value = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
pluginOutputParamsEditable.value = false;
break;
}
// No default
}
};
const requestMethodOptions = [
{
label: 'POST',
@@ -259,30 +112,259 @@ const requestMethodOptions = [
value: 'PATCH',
},
];
const runTestRef = ref();
const handleOpenRunModal = () => {
onMounted(() => {
if (!toolId.value) {
return;
}
getPluginToolInfo();
});
const workflowParameterRows = computed(() => [
...flattenParameterRows(pluginInputData.value, 'input'),
...flattenParameterRows(pluginOutputData.value, 'output'),
]);
function getPluginToolInfo() {
api
.post('/api/v1/pluginItem/tool/search', {
aiPluginToolId: toolId.value,
})
.then((res) => {
if (res.errorCode === 0) {
pluginToolInfo.value = res.data.data;
pluginInfo.value = res.data.aiPlugin;
pluginInputData.value = JSON.parse(res.data.data.inputData || '[]');
pluginOutputData.value = JSON.parse(res.data.data.outputData || '[]');
isWorkflowPlugin.value = Number(res.data.aiPlugin?.type || 1) === 2;
pluginUnavailable.value = res.data.aiPlugin?.available === false;
pluginUnavailableReason.value = res.data.aiPlugin?.reasonMessage || '';
workflowSnapshot.value = res.data.workflowSnapshot || null;
}
});
}
function handleClickHeader(index: number) {
switch (index) {
case 1: {
pluginBasicCollapse.value.isOpen = !pluginBasicCollapse.value.isOpen;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isOpen =
!pluginBasicCollapseInputParams.value.isOpen;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isOpen =
!pluginBasicCollapseOutputParams.value.isOpen;
break;
}
}
}
function back() {
router.back();
}
function updatePluginTool(index: number) {
if (index === 1) {
if (!saveForm.value) {
return;
}
saveForm.value.validate((valid: boolean) => {
if (valid) {
updatePluginToolInfo(index);
}
});
return;
}
updatePluginToolInfo(index);
}
function updatePluginToolInfo(index: number) {
api
.post('/api/v1/pluginItem/tool/update', {
id: toolId.value,
name: pluginToolInfo.value.name,
englishName: pluginToolInfo.value.englishName,
description: pluginToolInfo.value.description,
basePath: pluginToolInfo.value.basePath,
requestMethod: pluginToolInfo.value.requestMethod,
inputData: JSON.stringify(pluginInputData.value),
outputData: JSON.stringify(pluginOutputData.value),
})
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.updateOkMessage'));
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
break;
}
}
}
});
}
function handleEdit(index: number) {
if (isWorkflowPlugin.value) {
return;
}
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = true;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = true;
pluginBasicCollapseInputParams.value.isOpen = true;
pluginInputParamsEditable.value = true;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = true;
pluginBasicCollapseOutputParams.value.isOpen = true;
pluginOutputParamsEditable.value = true;
break;
}
}
}
function handleSave(index: number) {
if (index === 2) {
pluginInputParamsRef.value.handleSubmitParams();
}
if (index === 3) {
pluginOutputParamsRef.value.handleSubmitParams();
}
pluginInputParamsEditable.value = false;
updatePluginTool(index);
}
function handleCancel(index: number) {
getPluginToolInfo();
switch (index) {
case 1: {
pluginBasicCollapse.value.isEdit = false;
break;
}
case 2: {
pluginBasicCollapseInputParams.value.isEdit = false;
pluginInputParamsEditable.value = false;
break;
}
case 3: {
pluginBasicCollapseOutputParams.value.isEdit = false;
pluginOutputParamsEditable.value = false;
break;
}
}
}
function handleOpenRunModal() {
runTestRef.value.openDialog();
};
}
function flattenParameterRows(
parameters: any[] = [],
direction: 'input' | 'output',
parentPath = '',
): any[] {
return parameters.flatMap((item) => {
const currentName = String(item?.name || '').trim();
const currentPath = currentName
? parentPath
? `${parentPath}.${currentName}`
: currentName
: parentPath || '-';
const currentRow = {
key: String(item?.id || item?.key || currentPath),
direction,
name: currentPath,
type: String(item?.dataType || item?.type || '-'),
};
const children = Array.isArray(item?.children)
? flattenParameterRows(item.children, direction, currentPath)
: [];
return [currentRow, ...children];
});
}
</script>
<template>
<div class="accordion-container">
<div class="controls-header">
<ElButton @click="back" :icon="Back">
<ElButton :icon="Back" @click="back">
{{ $t('button.back') }}
</ElButton>
<ElButton type="primary" :icon="VideoPlay" @click="handleOpenRunModal">
{{ $t('pluginItem.pluginToolEdit.trialRun') }}
</ElButton>
</div>
<!-- 折叠面板列表 -->
<div class="accordion-list">
<!-- 基本信息-->
<template v-if="isWorkflowPlugin">
<div class="workflow-tool-layout">
<section class="workflow-tool-card">
<div class="workflow-tool-card__header">
<div class="workflow-tool-card__title">
{{ $t('pluginItem.inputData') }} / {{ $t('pluginItem.outputData') }}
</div>
</div>
<ElTable
:data="workflowParameterRows"
border
class="workflow-tool-table"
empty-text="-"
>
<ElTableColumn
prop="direction"
:label="$t('pluginItem.direction')"
width="120"
>
<template #default="{ row }">
{{ row.direction === 'input' ? $t('pluginItem.inputData') : $t('pluginItem.outputData') }}
</template>
</ElTableColumn>
<ElTableColumn
prop="name"
:label="$t('pluginItem.parameterName')"
min-width="280"
/>
<ElTableColumn
prop="type"
:label="$t('pluginItem.parameterType')"
min-width="160"
/>
</ElTable>
</section>
<section class="workflow-tool-card">
<div class="workflow-tool-card__header">
<div class="workflow-tool-card__title">
快照
</div>
</div>
<WorkflowApprovalSnapshotPreview
:title="workflowSnapshot?.title || pluginInfo.workflowTitle || pluginToolInfo.name"
:description="''"
:content="workflowSnapshot?.content || ''"
/>
</section>
</div>
</template>
<div v-else class="accordion-list">
<div
class="accordion-item"
:class="{ 'accordion-item--active': pluginBasicCollapse.isOpen }"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="handleClickHeader(1)">
<div class="column-header-container">
<div
@@ -295,37 +377,38 @@ const handleOpenRunModal = () => {
</div>
<h3 class="accordion-title">{{ pluginBasicCollapse.title }}</h3>
</div>
<div>
<div v-if="!isWorkflowPlugin">
<ElButton
@click.stop="handleEdit(1)"
type="primary"
v-if="!pluginBasicCollapse.isEdit"
type="primary"
@click.stop="handleEdit(1)"
>
{{ $t('button.edit') }}
</ElButton>
<ElButton
@click.stop="handleCancel(1)"
v-if="pluginBasicCollapse.isEdit"
@click.stop="handleCancel(1)"
>
{{ $t('button.cancel') }}
</ElButton>
<ElButton
@click.stop="handleSave(1)"
type="primary"
v-if="pluginBasicCollapse.isEdit"
type="primary"
@click.stop="handleSave(1)"
>
{{ $t('button.save') }}
</ElButton>
</div>
<ElTag v-else type="info" effect="plain">
{{ $t('pluginItem.systemManaged') }}
</ElTag>
</div>
<!-- 面板内容 -->
<div
class="accordion-content"
:class="{ 'accordion-content--open': pluginBasicCollapse.isOpen }"
>
<div class="accordion-content-inner">
<!--编辑基本信息-->
<div v-show="pluginBasicCollapse.isEdit">
<div class="plugin-tool-info-edit-container">
<ElForm
@@ -381,7 +464,6 @@ const handleOpenRunModal = () => {
</ElForm>
</div>
</div>
<!--显示基本信息-->
<div
v-show="!pluginBasicCollapse.isEdit"
class="plugin-tool-info-view-container"
@@ -402,32 +484,39 @@ const handleOpenRunModal = () => {
</div>
<div>{{ pluginToolInfo.description }}</div>
</div>
<div class="plugin-tool-view-item">
<div v-if="isWorkflowPlugin" class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.pluginToolEdit.toolPath') }}:
{{ $t('pluginItem.pluginToolEdit.workflowTarget') }}:
</div>
<div>{{ pluginInfo.baseUrl }}{{ pluginToolInfo.basePath }}</div>
<div>{{ pluginInfo.workflowTitle || '-' }}</div>
</div>
<div class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.pluginToolEdit.requestMethod') }}:
<template v-else>
<div class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.pluginToolEdit.toolPath') }}:
</div>
<div>{{ pluginInfo.baseUrl }}{{ pluginToolInfo.basePath }}</div>
</div>
<div>
{{ pluginToolInfo.requestMethod }}
<div class="plugin-tool-view-item">
<div class="view-item-title">
{{ $t('pluginItem.pluginToolEdit.requestMethod') }}:
</div>
<div>
{{ pluginToolInfo.requestMethod }}
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- 输入参数-->
<div
class="accordion-item"
:class="{
'accordion-item--active': pluginBasicCollapseInputParams.isOpen,
}"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="handleClickHeader(2)">
<div class="column-header-container">
<div
@@ -445,31 +534,30 @@ const handleOpenRunModal = () => {
{{ pluginBasicCollapseInputParams.title }}
</h3>
</div>
<div>
<div v-if="!isWorkflowPlugin">
<ElButton
@click.stop="handleEdit(2)"
type="primary"
v-if="!pluginBasicCollapseInputParams.isEdit"
type="primary"
@click.stop="handleEdit(2)"
>
{{ $t('button.edit') }}
</ElButton>
<ElButton
@click.stop="handleCancel(2)"
v-if="pluginBasicCollapseInputParams.isEdit"
@click.stop="handleCancel(2)"
>
{{ $t('button.cancel') }}
</ElButton>
<ElButton
@click.stop="handleSave(2)"
type="primary"
v-if="pluginBasicCollapseInputParams.isEdit"
type="primary"
@click.stop="handleSave(2)"
>
{{ $t('button.save') }}
</ElButton>
</div>
</div>
<!--输入参数-->
<div
class="accordion-content"
:class="{
@@ -486,14 +574,13 @@ const handleOpenRunModal = () => {
</div>
</div>
</div>
<!-- 输出参数-->
<div
class="accordion-item"
:class="{
'accordion-item--active': pluginBasicCollapseOutputParams.isOpen,
}"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="handleClickHeader(3)">
<div class="column-header-container">
<div
@@ -511,31 +598,30 @@ const handleOpenRunModal = () => {
{{ pluginBasicCollapseOutputParams.title }}
</h3>
</div>
<div>
<div v-if="!isWorkflowPlugin">
<ElButton
@click.stop="handleEdit(3)"
type="primary"
v-if="!pluginBasicCollapseOutputParams.isEdit"
type="primary"
@click.stop="handleEdit(3)"
>
{{ $t('button.edit') }}
</ElButton>
<ElButton
@click.stop="handleCancel(3)"
v-if="pluginBasicCollapseOutputParams.isEdit"
@click.stop="handleCancel(3)"
>
{{ $t('button.cancel') }}
</ElButton>
<ElButton
@click.stop="handleSave(3)"
type="primary"
v-if="pluginBasicCollapseOutputParams.isEdit"
type="primary"
@click.stop="handleSave(3)"
>
{{ $t('button.save') }}
</ElButton>
</div>
</div>
<!--输出参数-->
<div
class="accordion-content"
:class="{
@@ -544,8 +630,8 @@ const handleOpenRunModal = () => {
>
<div class="accordion-content-inner">
<PluginInputAndOutParams
v-model="pluginOutputData"
ref="pluginOutputParamsRef"
v-model="pluginOutputData"
:editable="pluginOutputParamsEditable"
:is-edit-output="true"
/>
@@ -553,211 +639,133 @@ const handleOpenRunModal = () => {
</div>
</div>
</div>
<!-- 试运行模态框-->
<PluginRunTestModal ref="runTestRef" :plugin-tool-id="toolId" />
</div>
</template>
<style scoped>
/* 响应式设计 */
@media (max-width: 768px) {
.accordion-container {
padding: 15px;
}
.controls {
flex-direction: column;
gap: 15px;
align-items: stretch;
}
.control-group {
justify-content: center;
}
.title {
font-size: 1.5rem;
}
.accordion-header {
padding: 14px 16px;
}
.accordion-title {
font-size: 1rem;
}
}
.accordion-container {
max-width: 100%;
padding: 20px;
margin: 0 auto;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
}
.controls-header {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.title {
margin-bottom: 8px;
font-size: 2rem;
font-weight: 600;
color: var(--el-text-color-secondary);
text-align: center;
}
.subtitle {
margin-bottom: 30px;
font-size: 1.1rem;
color: var(--el-text-color-secondary);
text-align: center;
}
/* 控制面板样式 */
.controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
margin-bottom: 30px;
background: var(--el-bg-color);
border: 1px solid #e9ecef;
border-radius: 8px;
}
.control-group {
display: flex;
gap: 15px;
align-items: center;
}
.checkbox-label {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: pointer;
}
.checkbox {
width: 16px;
height: 16px;
}
.control-btn {
padding: 8px 16px;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: var(--el-bg-color);
border-radius: 4px;
transition: all 0.3s ease;
}
.control-btn:hover {
background: #3498db;
background: var(--el-color-primary-light-9);
}
/* 折叠面板列表 */
.accordion-list {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 20px;
gap: 16px;
}
.workflow-tool-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.workflow-tool-card {
padding: 20px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
}
.workflow-tool-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.workflow-tool-card__title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.workflow-tool-table {
width: 100%;
}
.accordion-item {
overflow: hidden;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
transition: all 0.3s ease;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
}
.accordion-item:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
.accordion-item--active {
border-color: var(--el-color-primary-light-5);
}
.accordion-header {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
padding: 18px 20px;
cursor: pointer;
user-select: none;
background: hsl(var(--background));
transition: background-color 0.3s ease;
}
.accordion-title {
padding-left: 12px;
margin: 0;
font-size: 1.1rem;
font-weight: 500;
.column-header-container {
display: flex;
gap: 10px;
align-items: center;
}
.accordion-icon {
font-size: 12px;
color: #7f8c8d;
transition: transform 0.3s ease;
display: inline-flex;
transition: transform 0.2s ease;
}
.accordion-icon--rotated {
transform: rotate(180deg);
}
.accordion-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.accordion-content {
max-height: 0;
overflow: hidden;
background: hsl(var(--background));
transition: max-height 0.4s ease;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
}
.accordion-content--open {
max-height: 2000px;
grid-template-rows: 1fr;
}
.accordion-content-inner {
padding: 20px;
border-top: 1px solid hsl(var(--border));
}
.accordion-content-inner p {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.column-header-container {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 0;
padding: 0 20px 20px;
overflow: hidden;
}
.plugin-tool-info-view-container {
display: flex;
flex-direction: column;
gap: 25px;
gap: 12px;
}
.plugin-tool-view-item {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
line-height: 22px;
}
.view-item-title {
width: 70px;
/* text-align: right; */
/* margin-right: 12px; */
min-width: 108px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -25,6 +25,10 @@ const props = defineProps({
required: true,
type: String,
},
pluginType: {
default: 1,
type: Number,
},
});
const router = useRouter();
defineExpose({
@@ -100,7 +104,7 @@ const pluginToolReload = () => {
{{ $t('button.edit') }}
</ElButton>
<ElDropdown>
<ElDropdown v-if="Number(props.pluginType || 1) !== 2">
<ElButton link :icon="MoreFilled" />
<template #dropdown>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { markRaw, ref } from 'vue';
import { computed, markRaw, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { Back, Plus } from '@element-plus/icons-vue';
import { api } from '#/api/request';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PluginToolTable from '#/views/ai/plugin/PluginToolTable.vue';
@@ -13,26 +14,49 @@ const route = useRoute();
const router = useRouter();
const pluginId = ref<string>((route.query.id as string) || '');
const headerButtons = [
{
key: 'back',
text: $t('button.back'),
icon: markRaw(Back),
data: { action: 'back' },
},
{
key: 'createTool',
text: $t('pluginItem.createPluginTool'),
icon: markRaw(Plus),
type: 'primary',
data: { action: 'createTool' },
},
];
const handleSearch = (params: any) => {
const pluginInfo = ref<any>({});
const pluginToolRef = ref();
const headerButtons = computed<any[]>(() => {
const buttons: any[] = [
{
key: 'back',
text: $t('button.back'),
icon: markRaw(Back),
data: { action: 'back' },
},
];
if (Number(pluginInfo.value?.type || 1) !== 2) {
buttons.push({
key: 'createTool',
text: $t('pluginItem.createPluginTool'),
icon: markRaw(Plus),
type: 'primary',
data: { action: 'createTool' },
});
}
return buttons;
});
onMounted(() => {
loadPluginInfo();
});
async function loadPluginInfo() {
if (!pluginId.value) {
return;
}
const res = await api.get(`/api/v1/plugin/detail?id=${pluginId.value}`);
if (res.errorCode === 0) {
pluginInfo.value = res.data || {};
}
}
function handleSearch(params: any) {
pluginToolRef.value.handleSearch(params);
};
const handleButtonClick = (event: any) => {
// 根据按钮 key 执行不同操作
}
function handleButtonClick(event: any) {
switch (event.key) {
case 'back': {
router.push({ path: '/ai/plugin' });
@@ -43,8 +67,7 @@ const handleButtonClick = (event: any) => {
break;
}
}
};
const pluginToolRef = ref();
}
</script>
<template>
@@ -56,7 +79,11 @@ const pluginToolRef = ref();
/>
<div class="bg-background border-border flex-1 rounded-lg border p-5">
<PluginToolTable :plugin-id="pluginId" ref="pluginToolRef" />
<PluginToolTable
ref="pluginToolRef"
:plugin-id="pluginId"
:plugin-type="pluginInfo.type"
/>
</div>
</div>
</template>

View File

@@ -8,8 +8,10 @@ export interface OfflineImpactBinding {
export interface OfflineImpactCheck {
canProceed: boolean;
hasBotBindings: boolean;
hasPluginBindings: boolean;
hasWorkflowUsages: boolean;
botBindings: OfflineImpactBinding[];
pluginBindings: OfflineImpactBinding[];
workflowUsages: OfflineImpactBinding[];
message?: string;
}

View File

@@ -605,9 +605,25 @@ function handlePluginNodeUpdate(chooseId: any) {
})
.then((res) => {
pageLoading.value = false;
updatePluginNode.value(res.data);
updatePluginNode.value(decoratePluginNodeData(res.data));
});
}
function decoratePluginNodeData(data: any) {
if (!data || Number(data.pluginType || 1) !== 2) {
return data;
}
const statusText =
data.available === false
? data.reasonMessage || $t('plugin.workflowPluginUnavailable')
: $t('plugin.workflowSnapshotSynced');
return {
...data,
pluginName: data.pluginName
? `${data.pluginName} · ${statusText}`
: statusText,
pluginStatusText: statusText,
};
}
function onAsyncExecute(info: any) {
chainInfo.value = info;
}
@@ -628,7 +644,7 @@ function onAsyncExecute(info: any) {
:title="$t('menus.ai.plugin')"
width="730"
ref="pluginSelectRef"
page-url="/api/v1/plugin/page"
page-url="/api/v1/plugin/page?availableOnly=true"
:has-parent="true"
single-select
@get-data="(v) => handleChoose(nodeNames.pluginNode, v)"

View File

@@ -6,7 +6,7 @@ import type {
CardPrimaryAction,
} from '#/components/page/CardList.vue';
import { computed, markRaw, onMounted, ref } from 'vue';
import { computed, h, markRaw, onMounted, ref } from 'vue';
import { useAccess } from '@easyflow/access';
import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -360,14 +360,48 @@ async function submitOfflineAction(row: any) {
return;
}
try {
const sections = [];
if (impactRes.data?.hasBotBindings) {
sections.push(
buildOfflineImpactMessage(
$t('aiWorkflow.offlineImpactBoundBotsIntro'),
impactRes.data.botBindings,
impactRes.data?.hasPluginBindings
? undefined
: $t('aiWorkflow.offlineImpactBoundBotsFooter'),
),
);
}
if (impactRes.data?.hasPluginBindings) {
sections.push(
buildOfflineImpactMessage(
$t('aiWorkflow.offlineImpactBoundPluginsIntro'),
impactRes.data.pluginBindings,
impactRes.data?.hasBotBindings
? undefined
: $t('aiWorkflow.offlineImpactBoundPluginsFooter'),
),
);
}
const impactMessage =
sections.length > 0
? h('div', [
...sections,
h(
'p',
{
style: 'margin-top: 12px;',
},
impactRes.data?.hasBotBindings && impactRes.data?.hasPluginBindings
? $t('aiWorkflow.offlineImpactBoundMixedFooter')
: impactRes.data?.hasPluginBindings
? $t('aiWorkflow.offlineImpactBoundPluginsFooter')
: $t('aiWorkflow.offlineImpactBoundBotsFooter'),
),
])
: $t('aiWorkflow.submitOfflineApprovalConfirm');
await ElMessageBox.confirm(
impactRes.data?.hasBotBindings
? buildOfflineImpactMessage(
$t('aiWorkflow.offlineImpactBoundBotsIntro'),
impactRes.data.botBindings,
$t('aiWorkflow.offlineImpactBoundBotsFooter'),
)
: $t('aiWorkflow.submitOfflineApprovalConfirm'),
impactMessage,
$t('message.noticeTitle'),
{
confirmButtonText: $t('button.confirm'),

View File

@@ -4,11 +4,12 @@ import type { FormInstance } from 'element-plus';
import { ref } from 'vue';
import { Position } from '@element-plus/icons-vue';
import { ElButton, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { ElButton, ElForm, ElFormItem, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import ShowJson from '#/components/json/ShowJson.vue';
import { $t } from '#/locales';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
interface Props {
workflowId: any;
@@ -47,19 +48,10 @@ function submit() {
<template>
<div>
<ElForm label-position="top" ref="singleRunForm" :model="runParams">
<ElFormItem
v-for="(item, idx) in node?.data.parameters"
:prop="item.name"
:key="idx"
:label="item.description || item.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ElInput
v-if="item.formType === 'input' || !item.formType"
v-model="runParams[item.name]"
:placeholder="item.formPlaceholder"
/>
</ElFormItem>
<WorkflowFormItem
v-model:run-params="runParams"
:parameters="node?.data.parameters || []"
/>
<ElFormItem>
<ElButton
type="primary"

View File

@@ -29,16 +29,64 @@ export interface WorkflowStepsProps {
nodeJson: any;
initSignal?: boolean;
pollingData?: any;
expandAll?: boolean;
}
const props = defineProps<WorkflowStepsProps>();
const emit = defineEmits(['resume']);
const nodes = ref<any[]>([]);
const nodeStatusMap = ref<Record<string, any>>({});
const isChainError = ref(false);
const activeName = ref<any>(props.expandAll ? [] : '');
const confirmParams = ref<any>({});
// 定义一个对象来存储所有的 form 实例key 为 node.key
const formRefs = ref<Record<string, FormInstance>>({});
const confirmBtnLoading = ref(false);
const chainErrMsg = ref('');
function shouldAutoExpandStatus(status: unknown) {
return [1, 5, 20, 21].includes(Number(status));
}
function isExpandedNode(nodeKey: string) {
if (Array.isArray(activeName.value)) {
return activeName.value.includes(nodeKey);
}
return activeName.value === nodeKey;
}
function hasNodePayloadChanged(previous: any, current: any) {
if (previous === current) {
return false;
}
return JSON.stringify(previous ?? null) !== JSON.stringify(current ?? null);
}
function hasNodeStateChanged(previous: any, current: any) {
if (!previous) {
return true;
}
if ((previous?.status || null) !== (current?.status || null)) {
return true;
}
if ((previous?.message || '') !== (current?.message || '')) {
return true;
}
if (hasNodePayloadChanged(previous?.result, current?.result)) {
return true;
}
return hasNodePayloadChanged(
previous?.suspendForParameters,
current?.suspendForParameters,
);
}
watch(
() => props.pollingData,
(newVal) => {
const nodes = newVal.nodes;
if (!newVal) {
return;
}
const currentNodes = newVal.nodes || {};
if (newVal.status === 21) {
isChainError.value = true;
chainErrMsg.value = newVal.message;
@@ -46,9 +94,20 @@ watch(
if (![20, 21].includes(newVal.status)) {
confirmBtnLoading.value = false;
}
for (const nodeId in nodes) {
nodeStatusMap.value[nodeId] = nodes[nodeId];
if (nodes[nodeId].status === 5) {
for (const nodeId in currentNodes) {
const previousNodeState = nodeStatusMap.value[nodeId];
const currentNodeState = currentNodes[nodeId];
const previousStatus = previousNodeState?.status;
const currentStatus = currentNodeState?.status;
if (!hasNodeStateChanged(previousNodeState, currentNodeState)) {
continue;
}
nodeStatusMap.value[nodeId] = currentNodeState;
if (
!props.expandAll &&
previousStatus !== currentStatus &&
shouldAutoExpandStatus(currentStatus)
) {
activeName.value = nodeId;
}
}
@@ -62,14 +121,17 @@ watch(
isChainError.value = false;
confirmBtnLoading.value = false;
chainErrMsg.value = '';
activeName.value = props.expandAll ? [] : '';
},
);
watch(
() => props.nodeJson,
(newVal) => {
if (newVal) {
nodes.value = [...newVal];
}
const nextNodes = Array.isArray(newVal) ? [...newVal] : [];
nodes.value = nextNodes;
activeName.value = props.expandAll
? nextNodes.map((node: any) => node.key)
: '';
},
{ immediate: true },
);
@@ -79,18 +141,12 @@ const displayNodes = computed(() => {
...nodeStatusMap.value[node.key],
}));
});
const activeName = ref('1');
const confirmParams = ref<any>({});
// 定义一个对象来存储所有的 form 实例key 为 node.key
const formRefs = ref<Record<string, FormInstance>>({});
// 动态设置 Ref 的辅助函数
const setFormRef = (el: any, key: string) => {
if (el) {
formRefs.value[key] = el as FormInstance;
}
};
const confirmBtnLoading = ref(false);
const chainErrMsg = ref('');
function getSelectMode(ops: any) {
return ops.formType || 'radio';
}
@@ -124,7 +180,11 @@ function handleConfirm(node: any) {
<div class="mb-1">
<ElAlert v-if="chainErrMsg" :title="chainErrMsg" type="error" />
</div>
<ElCollapse v-model="activeName" accordion expand-icon-position="left">
<ElCollapse
v-model="activeName"
:accordion="!props.expandAll"
expand-icon-position="left"
>
<ElCollapseItem
v-for="node in displayNodes"
:key="node.key"
@@ -165,64 +225,66 @@ function handleConfirm(node: any) {
</div>
</div>
</template>
<div v-if="node.original.type === 'confirmNode'" class="p-2.5">
<div class="mb-2 text-[16px] font-bold">
{{ node.original.data.message }}
</div>
<ElForm
:ref="(el) => setFormRef(el, node.key)"
label-position="top"
:model="confirmParams"
>
<template
v-for="(ops, idx) in node.suspendForParameters"
:key="idx"
<template v-if="isExpandedNode(node.key)">
<div v-if="node.original.type === 'confirmNode'" class="p-2.5">
<div class="mb-2 text-[16px] font-bold">
{{ node.original.data.message }}
</div>
<ElForm
:ref="(el) => setFormRef(el, node.key)"
label-position="top"
:model="confirmParams"
>
<div class="header-container" v-if="ops.formType !== 'confirm'">
<div class="blue-bar">&nbsp;</div>
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
</div>
<div
class="description-container"
v-if="ops.formType !== 'confirm'"
<template
v-for="(ops, idx) in node.suspendForParameters"
:key="idx"
>
{{ ops.formDescription }}
</div>
<ElFormItem
v-if="ops.formType !== 'confirm'"
:prop="ops.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ConfirmItem
v-if="getSelectMode(ops) === 'radio'"
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
<ConfirmItemMulti
v-else
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
</ElFormItem>
</template>
<ElFormItem v-if="node.suspendForParameters?.length > 0">
<div class="flex justify-end">
<ElButton
:disabled="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
<div class="header-container" v-if="ops.formType !== 'confirm'">
<div class="blue-bar">&nbsp;</div>
<span>{{ ops.formLabel || $t('message.confirmItem') }}</span>
</div>
<div
class="description-container"
v-if="ops.formType !== 'confirm'"
>
{{ $t('button.confirm') }}
</ElButton>
</div>
</ElFormItem>
</ElForm>
</div>
<div v-else>
<ShowJson :value="node.result || node.message" />
</div>
{{ ops.formDescription }}
</div>
<ElFormItem
v-if="ops.formType !== 'confirm'"
:prop="ops.name"
:rules="[{ required: true, message: $t('message.required') }]"
>
<ConfirmItem
v-if="getSelectMode(ops) === 'radio'"
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
<ConfirmItemMulti
v-else
v-model="confirmParams[ops.name]"
:selection-data-type="ops.contentType || 'text'"
:selection-data="ops.enums"
/>
</ElFormItem>
</template>
<ElFormItem v-if="node.suspendForParameters?.length > 0">
<div class="flex justify-end">
<ElButton
:disabled="confirmBtnLoading"
type="primary"
@click="handleConfirm(node)"
>
{{ $t('button.confirm') }}
</ElButton>
</div>
</ElFormItem>
</ElForm>
</div>
<div v-else>
<ShowJson :value="node.result || node.message" />
</div>
</template>
</ElCollapseItem>
</ElCollapse>
</div>

View File

@@ -37,6 +37,7 @@
let inputEl = $state<HTMLInputElement | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(null);
let highlightEl = $state<HTMLDivElement | null>(null);
let scrollbarWidth = $state(0);
let triggerObject: any;
let isFocused = $state(false);
let isComposing = $state(false);
@@ -49,6 +50,17 @@
}
});
$effect(() => {
localValue;
if (mode !== 'textarea') {
return;
}
requestAnimationFrame(() => {
updateScrollbarWidth();
});
});
const paramCandidates = $derived(flattenParameterCandidates(parameters));
const paramNames = $derived(paramCandidates.map((item) => item.name));
const unresolvedParamSet = $derived.by(() => {
@@ -129,9 +141,48 @@
highlightEl.scrollLeft = el.scrollLeft;
};
const updateScrollbarWidth = () => {
const el = textareaEl;
if (!el || mode !== 'textarea') {
scrollbarWidth = 0;
return;
}
const style = getComputedStyle(el);
const borderLeft = parseFloat(style.borderLeftWidth || '0');
const borderRight = parseFloat(style.borderRightWidth || '0');
const nextWidth = Math.max(0, el.offsetWidth - el.clientWidth - borderLeft - borderRight);
scrollbarWidth = Number.isFinite(nextWidth) ? nextWidth : 0;
};
const syncEditorMetrics = () => {
syncScroll();
updateScrollbarWidth();
};
$effect(() => {
if (mode !== 'textarea' || !textareaEl) {
scrollbarWidth = 0;
return;
}
const resizeObserver = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => {
updateScrollbarWidth();
})
: null;
updateScrollbarWidth();
resizeObserver?.observe(textareaEl);
return () => {
resizeObserver?.disconnect();
};
});
const handleInput = (event: Event) => {
localValue = ((event.target as HTMLInputElement | HTMLTextAreaElement).value || '') as string;
syncScroll();
syncEditorMetrics();
emitInput(event);
};
@@ -158,7 +209,7 @@
newEditorEl.focus();
newEditorEl.setSelectionRange(result.cursor, result.cursor);
}
syncScroll();
syncEditorMetrics();
triggerObject?.hide?.();
};
@@ -310,7 +361,7 @@
editorEl.focus();
editorEl.setSelectionRange(deleteRange.start, deleteRange.start);
}
syncScroll();
syncEditorMetrics();
rest.onkeydown?.(event);
return;
}
@@ -339,7 +390,7 @@
editorEl.focus();
editorEl.setSelectionRange(tokenRange.start, tokenRange.start);
}
syncScroll();
syncEditorMetrics();
rest.onkeydown?.(event);
};
@@ -400,6 +451,7 @@
<div
class="param-token-editor-highlight {mode === 'input' ? 'single-line' : 'multi-line'}"
bind:this={highlightEl}
style={`--param-token-scrollbar-width: ${scrollbarWidth}px;`}
aria-hidden="true"
>
{@html highlightedHtml}
@@ -571,7 +623,9 @@
inset: 0;
border: 1px solid transparent;
border-radius: 5px;
padding: var(--param-token-padding-y) var(--param-token-padding-right) var(--param-token-padding-y)
padding: var(--param-token-padding-y)
calc(var(--param-token-padding-right) + var(--param-token-scrollbar-width, 0px))
var(--param-token-padding-y)
var(--param-token-padding-left);
box-sizing: border-box;
color: var(--tf-text-primary);
@@ -621,6 +675,8 @@
.param-token-textarea {
overflow: auto;
display: block;
scrollbar-gutter: stable;
}
.param-token-action {