feat(XL13): 归档工作流对话运行界面
- 接入发布快照优先与未发布草稿受控运行 - 支持文本和思考流式输出、循环多输出及实时运行详情 - 完成聊天分享、图片输入、中止与清空重来 - 补充后端与前端定向回归测试
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { readScopedRouteQueryParam } from './share-route-context';
|
||||
|
||||
/**
|
||||
* 工作流协作分享请求头。
|
||||
* 工作流对话分享请求头。
|
||||
*/
|
||||
export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Share-Key';
|
||||
export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Chat-Share-Key';
|
||||
|
||||
interface WorkflowShareResolutionOptions<T> {
|
||||
currentWorkflowId?: null | T;
|
||||
@@ -18,19 +18,14 @@ interface WorkflowShareHeaderOptions {
|
||||
requestUrl?: string;
|
||||
}
|
||||
|
||||
const WORKFLOW_SHARE_ROUTES = ['/share/workflow', '/ai/workflow/design'];
|
||||
const WORKFLOW_SHARE_ROUTES = ['/share/workflow'];
|
||||
const WORKFLOW_SHARE_REQUESTS = [
|
||||
['GET', '/api/v1/workflow/detail'],
|
||||
['GET', '/api/v1/workflow/getRunningParameters'],
|
||||
['GET', '/api/v1/workflow/publishApprovalRequirement'],
|
||||
['GET', '/api/v1/workflowChat/descriptor'],
|
||||
['GET', '/api/v1/workflowChat/execution'],
|
||||
['GET', '/api/v1/workflowShare/resolve'],
|
||||
['POST', '/api/v1/workflow/check'],
|
||||
['POST', '/api/v1/workflow/getChainStatus'],
|
||||
['POST', '/api/v1/workflow/resume'],
|
||||
['POST', '/api/v1/workflow/runAsync'],
|
||||
['POST', '/api/v1/workflow/singleRun'],
|
||||
['POST', '/api/v1/workflow/submitPublishApproval'],
|
||||
['POST', '/api/v1/workflow/update'],
|
||||
['POST', '/api/v1/workflowChat/cancel'],
|
||||
['POST', '/api/v1/workflowChat/resume'],
|
||||
['POST', '/api/v1/workflowChat/run'],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,134 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { sortNodes } from '@easyflow/utils';
|
||||
|
||||
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||
import { ElAvatar, ElButton, ElCard, ElCol, ElRow } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
|
||||
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
|
||||
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
|
||||
|
||||
onMounted(async () => {
|
||||
pageLoading.value = true;
|
||||
await Promise.all([getWorkflowInfo(workflowId.value), getRunningParams()]);
|
||||
pageLoading.value = false;
|
||||
});
|
||||
const pageLoading = ref(false);
|
||||
const route = useRoute();
|
||||
const workflowId = ref(route.query.id);
|
||||
const workflowInfo = ref<any>({});
|
||||
const runParams = ref<any>(null);
|
||||
const initState = ref(false);
|
||||
const tinyFlowData = ref<any>(null);
|
||||
const workflowForm = ref();
|
||||
async function getWorkflowInfo(workflowId: any) {
|
||||
api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
|
||||
workflowInfo.value = res.data;
|
||||
tinyFlowData.value = workflowInfo.value.content
|
||||
? JSON.parse(workflowInfo.value.content)
|
||||
: {};
|
||||
});
|
||||
}
|
||||
async function getRunningParams() {
|
||||
api
|
||||
.get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`)
|
||||
.then((res) => {
|
||||
runParams.value = res.data;
|
||||
});
|
||||
}
|
||||
function onSubmit() {
|
||||
initState.value = !initState.value;
|
||||
}
|
||||
function resumeChain(data: any) {
|
||||
workflowForm.value?.resume(data);
|
||||
}
|
||||
const chainInfo = ref<any>(null);
|
||||
function onAsyncExecute(info: any) {
|
||||
chainInfo.value = info;
|
||||
}
|
||||
import WorkflowChatPage from './components/WorkflowChatPage.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-loading="pageLoading"
|
||||
class="bg-background-deep flex h-full max-h-[calc(100vh-90px)] w-full flex-col gap-6 overflow-hidden p-6"
|
||||
>
|
||||
<div>
|
||||
<ElButton
|
||||
:icon="ArrowLeft"
|
||||
@click="router.replace({ path: '/ai/workflow' })"
|
||||
>
|
||||
{{ $t('button.back') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-[150px] shrink-0 items-center gap-6 rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] pl-11"
|
||||
>
|
||||
<ElAvatar
|
||||
class="shrink-0"
|
||||
:src="workflowInfo.icon ?? workflowIcon"
|
||||
:size="72"
|
||||
/>
|
||||
<div class="flex flex-col gap-5">
|
||||
<span class="text-2xl font-medium">{{ workflowInfo.title }}</span>
|
||||
<span class="text-base text-[#75808d]">{{
|
||||
workflowInfo.description
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ElRow class="h-full overflow-hidden" :gutter="10">
|
||||
<ElCol :span="10" class="h-full overflow-hidden">
|
||||
<div class="grid h-full grid-rows-2 gap-2.5">
|
||||
<ElCard shadow="never" style="height: 100%; overflow: auto">
|
||||
<div class="mb-2.5 font-semibold">
|
||||
{{ $t('aiWorkflow.params') }}:
|
||||
</div>
|
||||
<WorkflowForm
|
||||
v-if="runParams && tinyFlowData"
|
||||
ref="workflowForm"
|
||||
:workflow-id="workflowId"
|
||||
:workflow-params="runParams"
|
||||
:on-submit="onSubmit"
|
||||
:on-async-execute="onAsyncExecute"
|
||||
:tiny-flow-data="tinyFlowData"
|
||||
/>
|
||||
</ElCard>
|
||||
<ElCard shadow="never" style="height: 100%; overflow: auto">
|
||||
<div class="mb-2.5 font-semibold">
|
||||
{{ $t('aiWorkflow.steps') }}:
|
||||
</div>
|
||||
<WorkflowSteps
|
||||
v-if="tinyFlowData"
|
||||
:workflow-id="workflowId"
|
||||
:node-json="sortNodes(tinyFlowData)"
|
||||
:init-signal="initState"
|
||||
:polling-data="chainInfo"
|
||||
@resume="resumeChain"
|
||||
/>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :span="14">
|
||||
<ElCard shadow="never" style="height: 100%; overflow: auto">
|
||||
<div class="mb-2.5 mt-2.5 font-semibold">
|
||||
{{ $t('aiWorkflow.result') }}:
|
||||
</div>
|
||||
<ExecResult
|
||||
v-if="tinyFlowData"
|
||||
:workflow-id="workflowId"
|
||||
:node-json="sortNodes(tinyFlowData)"
|
||||
:init-signal="initState"
|
||||
:polling-data="chainInfo"
|
||||
/>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
<WorkflowChatPage />
|
||||
</template>
|
||||
|
||||
@@ -202,7 +202,9 @@ const actions: ActionButton[] = [
|
||||
text: $t('button.share'),
|
||||
permission: '/api/v1/workflow/save',
|
||||
placement: 'menu',
|
||||
disabled: (row: any) => sharingWorkflowId.value === row.id,
|
||||
disabled: (row: any) =>
|
||||
row.publishStatus !== 'PUBLISHED' ||
|
||||
sharingWorkflowId.value === row.id,
|
||||
loading: (row: any) => sharingWorkflowId.value === row.id,
|
||||
onClick: (row: any) => {
|
||||
shareWorkflow(row);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import WorkflowDesign from './WorkflowDesign.vue';
|
||||
import WorkflowChatPage from './components/WorkflowChatPage.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkflowDesign share-mode />
|
||||
<WorkflowChatPage share-mode />
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import WorkflowFormItem from './WorkflowFormItem.vue';
|
||||
import { resolveWorkflowFormParameters } from './workflowFormParameters';
|
||||
|
||||
export type WorkflowFormProps = {
|
||||
onAsyncExecute?: (values: any) => void;
|
||||
@@ -49,30 +50,9 @@ const startFormMeta = computed(() => {
|
||||
submitText: String(meta.submitText || '').trim() || $t('button.run'),
|
||||
};
|
||||
});
|
||||
const parameters = computed(() => {
|
||||
const schema = Array.isArray(props.workflowParams?.startFormSchema)
|
||||
? props.workflowParams.startFormSchema
|
||||
: [];
|
||||
if (schema.length === 0) {
|
||||
return props.workflowParams.parameters || [];
|
||||
}
|
||||
return schema.map((field: any) => {
|
||||
const type = String(field.type || '').trim() || 'text';
|
||||
return {
|
||||
name: field.key,
|
||||
formLabel: field.label || field.key,
|
||||
formDescription: field.description || '',
|
||||
formPlaceholder: field.placeholder || '',
|
||||
required: Boolean(field.required),
|
||||
defaultValue: field.defaultValue,
|
||||
enums: Array.isArray(field.options) ? field.options : [],
|
||||
contentType: type === 'file' ? 'file' : 'text',
|
||||
formType: type === 'text' ? 'input' : type === 'file' ? 'input' : type,
|
||||
dataType:
|
||||
type === 'checkbox' ? 'Array' : type === 'file' ? 'File' : 'String',
|
||||
};
|
||||
});
|
||||
});
|
||||
const parameters = computed(() =>
|
||||
resolveWorkflowFormParameters(props.workflowParams),
|
||||
);
|
||||
watch(
|
||||
parameters,
|
||||
(items) => {
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
import { $t } from '#/locales';
|
||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
|
||||
import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue';
|
||||
|
||||
import { hasWorkflowImageValue } from './workflowImageValue';
|
||||
|
||||
const props = defineProps({
|
||||
parameters: {
|
||||
@@ -37,7 +40,7 @@ function getContentType(item: any) {
|
||||
return 'text';
|
||||
}
|
||||
function isResource(contentType: any) {
|
||||
return ['audio', 'image', 'video'].includes(contentType);
|
||||
return ['audio', 'video'].includes(contentType);
|
||||
}
|
||||
function isFileContentType(contentType: any) {
|
||||
return contentType === 'file';
|
||||
@@ -61,6 +64,14 @@ function buildRules(item: any) {
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule: any, value: any, callback: any) => {
|
||||
if (getContentType(item) === 'image') {
|
||||
callback(
|
||||
hasWorkflowImageValue(value)
|
||||
? undefined
|
||||
: new Error($t('message.required')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
||||
return;
|
||||
@@ -144,6 +155,12 @@ function choose(data: any, propName: string) {
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="getContentType(item) === 'image'">
|
||||
<WorkflowImageInput
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isResource(getContentType(item))">
|
||||
<ElInput
|
||||
:model-value="runParams[item.name]"
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { ElButton, ElImage, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||
|
||||
import {
|
||||
buildWorkflowImageValueFromResource,
|
||||
buildWorkflowImageValueFromUpload,
|
||||
buildWorkflowImageValueFromUrl,
|
||||
formatWorkflowImageSize,
|
||||
getWorkflowImagePreviewUrl,
|
||||
normalizeWorkflowImageValue,
|
||||
validateWorkflowImageFile,
|
||||
WORKFLOW_IMAGE_LIMITS,
|
||||
} from './workflowImageValue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Object],
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const uploadLoading = ref(false);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const urlInput = ref('');
|
||||
|
||||
const currentImage = computed(() =>
|
||||
normalizeWorkflowImageValue(props.modelValue),
|
||||
);
|
||||
const previewUrl = computed(() =>
|
||||
getWorkflowImagePreviewUrl(props.modelValue),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const image = normalizeWorkflowImageValue(value);
|
||||
urlInput.value = image?.sourceType === 'url' ? image.url : '';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function applyUrl() {
|
||||
try {
|
||||
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '图片 URL 无效');
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSelectFile() {
|
||||
if (!uploadLoading.value) {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNativeFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
uploadLoading.value = true;
|
||||
try {
|
||||
validateWorkflowImageFile(file);
|
||||
const response = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||
emit(
|
||||
'update:modelValue',
|
||||
buildWorkflowImageValueFromUpload(file, response?.data?.path),
|
||||
);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '图片上传失败');
|
||||
console.error('工作流图片上传失败', error);
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleChooseResource(resource: any) {
|
||||
try {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
buildWorkflowImageValueFromResource(resource || {}),
|
||||
);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '图片素材选择失败');
|
||||
}
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
urlInput.value = '';
|
||||
emit('update:modelValue', undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workflow-image-input">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="workflow-image-input__native"
|
||||
type="file"
|
||||
:accept="WORKFLOW_IMAGE_LIMITS.accept"
|
||||
@change="handleNativeFileChange"
|
||||
/>
|
||||
|
||||
<div class="workflow-image-input__hint">
|
||||
支持 PNG、JPEG、WebP、GIF、BMP,单张不超过 10 MiB
|
||||
</div>
|
||||
|
||||
<div v-if="currentImage" class="workflow-image-input__preview">
|
||||
<ElImage
|
||||
class="workflow-image-input__thumbnail"
|
||||
:src="previewUrl"
|
||||
fit="cover"
|
||||
:preview-src-list="previewUrl ? [previewUrl] : []"
|
||||
preview-teleported
|
||||
/>
|
||||
<div class="workflow-image-input__summary">
|
||||
<div class="workflow-image-input__name">
|
||||
{{
|
||||
currentImage.sourceType === 'url'
|
||||
? currentImage.url
|
||||
: currentImage.fileName
|
||||
}}
|
||||
</div>
|
||||
<div class="workflow-image-input__meta">
|
||||
{{
|
||||
currentImage.sourceType === 'url'
|
||||
? '图片 URL'
|
||||
: formatWorkflowImageSize(currentImage.size) || '图片文件'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElInput
|
||||
v-model="urlInput"
|
||||
clearable
|
||||
placeholder="输入 HTTP/HTTPS 图片 URL"
|
||||
@keyup.enter="applyUrl"
|
||||
>
|
||||
<template #append>
|
||||
<ElButton @click="applyUrl">使用 URL</ElButton>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<div class="workflow-image-input__actions">
|
||||
<ElButton
|
||||
type="primary"
|
||||
plain
|
||||
:loading="uploadLoading"
|
||||
@click="triggerSelectFile"
|
||||
>
|
||||
{{ currentImage ? '替换图片' : $t('button.upload') }}
|
||||
</ElButton>
|
||||
<ChooseResource
|
||||
attr-name="image"
|
||||
:resource-type="0"
|
||||
@choose="handleChooseResource"
|
||||
/>
|
||||
<ElButton v-if="currentImage" text type="danger" @click="clearImage">
|
||||
清空
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-image-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workflow-image-input__native {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workflow-image-input__hint,
|
||||
.workflow-image-input__meta {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workflow-image-input__preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.workflow-image-input__thumbnail {
|
||||
width: 96px;
|
||||
height: 72px;
|
||||
flex: none;
|
||||
border-radius: var(--el-border-radius-small);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.workflow-image-input__summary {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workflow-image-input__name {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-image-input__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveWorkflowFormParameters } from '../workflowFormParameters';
|
||||
|
||||
describe('resolveWorkflowFormParameters', () => {
|
||||
it('uses the image parameter when a legacy schema still declares text', () => {
|
||||
const parameters = resolveWorkflowFormParameters({
|
||||
parameters: [
|
||||
{
|
||||
name: 'image_input',
|
||||
contentType: 'image',
|
||||
dataType: 'Object',
|
||||
formType: 'input',
|
||||
},
|
||||
],
|
||||
startFormSchema: [
|
||||
{
|
||||
key: 'image_input',
|
||||
label: '图片',
|
||||
type: 'text',
|
||||
contentType: 'text',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parameters).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'image_input',
|
||||
contentType: 'image',
|
||||
dataType: 'Object',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses a custom parameter name when the schema keeps a default label', () => {
|
||||
const parameters = resolveWorkflowFormParameters({
|
||||
parameters: [
|
||||
{
|
||||
name: '补充信息',
|
||||
formLabel: '文本字段',
|
||||
},
|
||||
],
|
||||
startFormSchema: [
|
||||
{
|
||||
key: '补充信息',
|
||||
label: '文本字段',
|
||||
type: 'text',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parameters[0]).toMatchObject({
|
||||
name: '补充信息',
|
||||
formLabel: '补充信息',
|
||||
required: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an explicitly customized field label', () => {
|
||||
const parameters = resolveWorkflowFormParameters({
|
||||
startFormSchema: [
|
||||
{
|
||||
key: 'context',
|
||||
label: '背景资料',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parameters[0]).toMatchObject({
|
||||
name: 'context',
|
||||
formLabel: '背景资料',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildWorkflowFormSubmissionImages,
|
||||
buildWorkflowFormSubmissionText,
|
||||
hasRequiredWorkflowFormParameters,
|
||||
} from '../workflowFormPresentation';
|
||||
|
||||
describe('workflowFormPresentation', () => {
|
||||
it('only requires the opening form when a required parameter exists', () => {
|
||||
expect(
|
||||
hasRequiredWorkflowFormParameters([
|
||||
{ name: 'optional', required: false },
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasRequiredWorkflowFormParameters([
|
||||
{ name: 'optional', required: false },
|
||||
{ name: 'required', required: true },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('shows entered form values using their configured parameter labels', () => {
|
||||
expect(
|
||||
buildWorkflowFormSubmissionText(
|
||||
[
|
||||
{ name: '补充信息', formLabel: '补充信息' },
|
||||
{ name: '附件', formLabel: '附件' },
|
||||
{ name: '未填写', formLabel: '未填写' },
|
||||
],
|
||||
{
|
||||
补充信息: '项目背景',
|
||||
附件: [{ fileName: '需求说明.pdf' }, { fileName: '接口定义.docx' }],
|
||||
未填写: '',
|
||||
},
|
||||
),
|
||||
).toBe('补充信息:项目背景\n附件:需求说明.pdf、接口定义.docx');
|
||||
});
|
||||
|
||||
it('renders image fields as image attachments instead of filename text', () => {
|
||||
const parameters = [
|
||||
{ name: '图片', formLabel: '图片', contentType: 'image' },
|
||||
{ name: '说明', formLabel: '说明', contentType: 'text' },
|
||||
];
|
||||
const values = {
|
||||
图片: {
|
||||
sourceType: 'upload',
|
||||
fileName: '界面截图.png',
|
||||
filePath: '/uploads/interface.png',
|
||||
contentType: 'image/png',
|
||||
size: 1024,
|
||||
},
|
||||
说明: '请检查布局',
|
||||
};
|
||||
|
||||
expect(buildWorkflowFormSubmissionText(parameters, values)).toBe(
|
||||
'说明:请检查布局',
|
||||
);
|
||||
expect(buildWorkflowFormSubmissionImages(parameters, values)).toEqual([
|
||||
{
|
||||
mimeType: 'image/png',
|
||||
name: '界面截图.png',
|
||||
previewUrl: '/uploads/interface.png',
|
||||
size: 1024,
|
||||
status: 'ready',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildWorkflowImageValueFromResource,
|
||||
buildWorkflowImageValueFromUpload,
|
||||
buildWorkflowImageValueFromUrl,
|
||||
hasWorkflowImageValue,
|
||||
normalizeWorkflowImageValue,
|
||||
validateWorkflowImageFile,
|
||||
} from '../workflowImageValue';
|
||||
|
||||
describe('workflowImageValue', () => {
|
||||
it('构建并归一化 URL 图片值', () => {
|
||||
expect(buildWorkflowImageValueFromUrl('https://example.com/a.png')).toEqual({
|
||||
sourceType: 'url',
|
||||
url: 'https://example.com/a.png',
|
||||
});
|
||||
expect(normalizeWorkflowImageValue('https://example.com/a.png')).toEqual({
|
||||
sourceType: 'url',
|
||||
url: 'https://example.com/a.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('构建上传和素材图片值', () => {
|
||||
const file = new File(['image'], 'a.png', { type: 'image/png' });
|
||||
expect(buildWorkflowImageValueFromUpload(file, '/files/a.png')).toMatchObject({
|
||||
sourceType: 'upload',
|
||||
fileName: 'a.png',
|
||||
filePath: '/files/a.png',
|
||||
});
|
||||
expect(
|
||||
buildWorkflowImageValueFromResource({
|
||||
fileSize: '128',
|
||||
resourceName: 'asset',
|
||||
resourceType: 0,
|
||||
resourceUrl: '/files/asset.webp',
|
||||
suffix: 'webp',
|
||||
}),
|
||||
).toMatchObject({
|
||||
sourceType: 'resource',
|
||||
fileName: 'asset.webp',
|
||||
filePath: '/files/asset.webp',
|
||||
size: 128,
|
||||
});
|
||||
});
|
||||
|
||||
it('兼容旧文件对象并执行内容感知必填判断', () => {
|
||||
const legacy = {
|
||||
fileName: 'legacy.jpg',
|
||||
filePath: '/files/legacy.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
expect(normalizeWorkflowImageValue(legacy)).toMatchObject({
|
||||
sourceType: 'upload',
|
||||
fileName: 'legacy.jpg',
|
||||
});
|
||||
expect(hasWorkflowImageValue(legacy)).toBe(true);
|
||||
expect(hasWorkflowImageValue({})).toBe(false);
|
||||
});
|
||||
|
||||
it('校验图片格式与 10 MiB 边界', () => {
|
||||
const accepted = new File(
|
||||
[new Uint8Array(10 * 1024 * 1024)],
|
||||
'accepted.png',
|
||||
{ type: 'image/png' },
|
||||
);
|
||||
const oversized = new File(
|
||||
[new Uint8Array(10 * 1024 * 1024 + 1)],
|
||||
'oversized.png',
|
||||
{ type: 'image/png' },
|
||||
);
|
||||
expect(() => validateWorkflowImageFile(accepted)).not.toThrow();
|
||||
expect(() => validateWorkflowImageFile(oversized)).toThrow(
|
||||
'单张图片不能超过 10 MiB',
|
||||
);
|
||||
expect(() =>
|
||||
validateWorkflowImageFile(
|
||||
new File(['text'], 'note.txt', { type: 'text/plain' }),
|
||||
),
|
||||
).toThrow('仅支持 PNG、JPEG、WebP、GIF、BMP 图片');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ChatTimelineMessageItem } from '@easyflow/common-ui';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
appendWorkflowStreamDelta,
|
||||
appendWorkflowThinkingDelta,
|
||||
createWorkflowStreamMessage,
|
||||
updateWorkflowStreamStatus,
|
||||
} from './workflowChatStreamMessage';
|
||||
|
||||
function createMessage(): ChatTimelineMessageItem {
|
||||
return appendWorkflowStreamDelta(
|
||||
createWorkflowStreamMessage('llm-stream-1', '大模型'),
|
||||
'首包',
|
||||
);
|
||||
}
|
||||
|
||||
describe('workflowChatStreamMessage', () => {
|
||||
it('appends later chunks with a new message and text-part reference', () => {
|
||||
const message = createMessage();
|
||||
const nextMessage = appendWorkflowStreamDelta(message, '后续内容');
|
||||
const answerPart = nextMessage.parts.find(
|
||||
(part) => part.id === 'llm-stream-1-answer',
|
||||
);
|
||||
|
||||
expect(nextMessage).not.toBe(message);
|
||||
expect(answerPart).not.toBe(message.parts[0]);
|
||||
expect(answerPart?.content).toBe('**大模型**\n\n首包后续内容');
|
||||
expect(message.parts[0]?.content).toBe('**大模型**\n\n首包');
|
||||
});
|
||||
|
||||
it('streams thinking separately and ends it when answer starts', () => {
|
||||
const message = createWorkflowStreamMessage('llm-stream-1', '大模型');
|
||||
const thinkingMessage = appendWorkflowThinkingDelta(message, '先分析');
|
||||
const nextThinkingMessage = appendWorkflowThinkingDelta(
|
||||
thinkingMessage,
|
||||
'再作答',
|
||||
);
|
||||
const answerMessage = appendWorkflowStreamDelta(
|
||||
nextThinkingMessage,
|
||||
'结论',
|
||||
);
|
||||
|
||||
expect(nextThinkingMessage.parts).toEqual([
|
||||
{
|
||||
content: '先分析再作答',
|
||||
id: 'llm-stream-1-thinking',
|
||||
status: 'thinking',
|
||||
type: 'thinking',
|
||||
},
|
||||
{
|
||||
content: '**大模型**',
|
||||
id: 'llm-stream-1-answer',
|
||||
type: 'text',
|
||||
},
|
||||
]);
|
||||
expect(answerMessage.parts).toEqual([
|
||||
{
|
||||
content: '先分析再作答',
|
||||
id: 'llm-stream-1-thinking',
|
||||
status: 'end',
|
||||
type: 'thinking',
|
||||
},
|
||||
{
|
||||
content: '**大模型**\n\n结论',
|
||||
id: 'llm-stream-1-answer',
|
||||
type: 'text',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores late thinking after answer output has started', () => {
|
||||
const message = createMessage();
|
||||
|
||||
expect(appendWorkflowThinkingDelta(message, '迟到内容')).toBe(message);
|
||||
});
|
||||
|
||||
it('completes thinking with a new message reference', () => {
|
||||
const message = appendWorkflowThinkingDelta(
|
||||
createWorkflowStreamMessage('llm-stream-1', '大模型'),
|
||||
'思考内容',
|
||||
);
|
||||
const nextMessage = updateWorkflowStreamStatus(message, 'done');
|
||||
|
||||
expect(nextMessage).not.toBe(message);
|
||||
expect(nextMessage.status).toBe('done');
|
||||
expect(nextMessage.parts[0]).toMatchObject({
|
||||
status: 'end',
|
||||
type: 'thinking',
|
||||
});
|
||||
expect(message.status).toBe('streaming');
|
||||
});
|
||||
|
||||
it('marks thinking as error when the stream fails', () => {
|
||||
const message = appendWorkflowThinkingDelta(
|
||||
createWorkflowStreamMessage('llm-stream-1', '大模型'),
|
||||
'思考内容',
|
||||
);
|
||||
|
||||
const nextMessage = updateWorkflowStreamStatus(message, 'error');
|
||||
|
||||
expect(nextMessage.parts[0]).toMatchObject({
|
||||
status: 'error',
|
||||
type: 'thinking',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { ChatTimelineMessageItem } from '@easyflow/common-ui';
|
||||
|
||||
const ANSWER_SEPARATOR = '\n\n';
|
||||
const ANSWER_PART_SUFFIX = '-answer';
|
||||
|
||||
/**
|
||||
* 创建工作流 LLM 流式消息。
|
||||
*
|
||||
* @param id 消息 ID
|
||||
* @param nodeName 节点名称
|
||||
* @returns 待接收思考或回答增量的消息
|
||||
*/
|
||||
export function createWorkflowStreamMessage(
|
||||
id: string,
|
||||
nodeName: string,
|
||||
): ChatTimelineMessageItem {
|
||||
return {
|
||||
id,
|
||||
parts: [
|
||||
{
|
||||
content: `**${nodeName}**`,
|
||||
id: `${id}${ANSWER_PART_SUFFIX}`,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
type: 'message',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 以不可变方式追加流式文本,确保时间线子组件能够观察到消息引用变化。
|
||||
*
|
||||
* @param message 当前流式消息
|
||||
* @param delta 本次新增文本
|
||||
* @returns 追加文本后的新消息
|
||||
*/
|
||||
export function appendWorkflowStreamDelta(
|
||||
message: ChatTimelineMessageItem,
|
||||
delta: string,
|
||||
): ChatTimelineMessageItem {
|
||||
const answerPartId = `${message.id}${ANSWER_PART_SUFFIX}`;
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((part) => {
|
||||
if (part.type === 'thinking' && part.status === 'thinking') {
|
||||
return {
|
||||
...part,
|
||||
status: 'end' as const,
|
||||
};
|
||||
}
|
||||
if (part.id === answerPartId && part.type === 'text') {
|
||||
const separator = part.content.includes(ANSWER_SEPARATOR)
|
||||
? ''
|
||||
: ANSWER_SEPARATOR;
|
||||
return {
|
||||
...part,
|
||||
content: `${part.content}${separator}${delta}`,
|
||||
};
|
||||
}
|
||||
return part;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 以不可变方式追加模型思考增量。
|
||||
*
|
||||
* @param message 当前流式消息
|
||||
* @param delta 本次新增思考内容
|
||||
* @returns 追加思考后的新消息;正式回答已开始时忽略迟到的思考片段
|
||||
*/
|
||||
export function appendWorkflowThinkingDelta(
|
||||
message: ChatTimelineMessageItem,
|
||||
delta: string,
|
||||
): ChatTimelineMessageItem {
|
||||
const answerPartId = `${message.id}${ANSWER_PART_SUFFIX}`;
|
||||
const answerPart = message.parts.find(
|
||||
(part) => part.id === answerPartId && part.type === 'text',
|
||||
);
|
||||
if (answerPart?.content.includes(ANSWER_SEPARATOR)) {
|
||||
return message;
|
||||
}
|
||||
const thinkingPart = message.parts.find((part) => part.type === 'thinking');
|
||||
if (!thinkingPart) {
|
||||
return {
|
||||
...message,
|
||||
parts: [
|
||||
{
|
||||
content: delta,
|
||||
id: `${message.id}-thinking`,
|
||||
status: 'thinking',
|
||||
type: 'thinking',
|
||||
},
|
||||
...message.parts,
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((part) =>
|
||||
part.id === thinkingPart.id && part.type === 'thinking'
|
||||
? {
|
||||
...part,
|
||||
content: `${part.content}${delta}`,
|
||||
status: 'thinking',
|
||||
}
|
||||
: part,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 以不可变方式更新流式消息状态。
|
||||
*
|
||||
* @param message 当前流式消息
|
||||
* @param status 新消息状态
|
||||
* @returns 更新状态后的新消息
|
||||
*/
|
||||
export function updateWorkflowStreamStatus(
|
||||
message: ChatTimelineMessageItem,
|
||||
status: ChatTimelineMessageItem['status'],
|
||||
): ChatTimelineMessageItem {
|
||||
return {
|
||||
...message,
|
||||
parts: message.parts.map((part) =>
|
||||
part.type === 'thinking'
|
||||
? {
|
||||
...part,
|
||||
status: status === 'error' ? 'error' : 'end',
|
||||
}
|
||||
: part,
|
||||
),
|
||||
status,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
finalizeWorkflowExecutionSteps,
|
||||
hydrateWorkflowExecutionSteps,
|
||||
reduceWorkflowExecutionSteps,
|
||||
} from './workflowExecutionDetails';
|
||||
|
||||
describe('workflowExecutionDetails', () => {
|
||||
it('keeps loop attempts separate and completes each output', () => {
|
||||
const first = reduceWorkflowExecutionSteps([], {
|
||||
data: {
|
||||
attemptKey: 'loop:1',
|
||||
input: { index: 0 },
|
||||
nodeId: 'llm',
|
||||
nodeName: '大模型',
|
||||
startedAt: 100,
|
||||
},
|
||||
eventId: '1',
|
||||
type: 'node_started',
|
||||
});
|
||||
const firstDone = reduceWorkflowExecutionSteps(
|
||||
first,
|
||||
{
|
||||
data: {
|
||||
attemptKey: 'loop:1',
|
||||
finishedAt: 150,
|
||||
nodeId: 'llm',
|
||||
nodeName: '大模型',
|
||||
output: { text: '第一轮完整输出' },
|
||||
status: 'SUCCEEDED',
|
||||
},
|
||||
eventId: '2',
|
||||
type: 'node_finished',
|
||||
},
|
||||
150,
|
||||
);
|
||||
const second = reduceWorkflowExecutionSteps(firstDone, {
|
||||
data: {
|
||||
attemptKey: 'loop:2',
|
||||
input: { index: 1 },
|
||||
nodeId: 'llm',
|
||||
nodeName: '大模型',
|
||||
startedAt: 200,
|
||||
},
|
||||
eventId: '3',
|
||||
type: 'node_started',
|
||||
});
|
||||
|
||||
expect(second).toHaveLength(2);
|
||||
expect(second[0]).toMatchObject({
|
||||
duration: 50,
|
||||
output: { text: '第一轮完整输出' },
|
||||
status: 'completed',
|
||||
});
|
||||
expect(second[1]).toMatchObject({
|
||||
attemptKey: 'loop:2',
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
it('appends condition decisions to the source attempt', () => {
|
||||
const started = reduceWorkflowExecutionSteps([], {
|
||||
data: {
|
||||
attemptKey: 'condition:1',
|
||||
nodeId: 'condition',
|
||||
nodeName: '条件判断',
|
||||
},
|
||||
eventId: '1',
|
||||
type: 'node_started',
|
||||
});
|
||||
const traced = reduceWorkflowExecutionSteps(started, {
|
||||
data: {
|
||||
attemptKey: 'condition:1',
|
||||
nodeId: 'condition',
|
||||
outcome: 'skipped',
|
||||
targetNodeName: '拒绝分支',
|
||||
},
|
||||
eventId: '2',
|
||||
type: 'node_trace',
|
||||
});
|
||||
|
||||
expect(traced[0]?.traces).toEqual([
|
||||
{
|
||||
id: '2',
|
||||
outcome: 'skipped',
|
||||
targetNodeName: '拒绝分支',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('hydrates persisted JSON values and finalizes active steps', () => {
|
||||
const hydrated = hydrateWorkflowExecutionSteps([
|
||||
{
|
||||
attemptKey: 'node:1',
|
||||
input: '{"question":"你好"}',
|
||||
nodeId: 'node',
|
||||
nodeName: '节点',
|
||||
output: '{"answer":"你好"}',
|
||||
status: 20,
|
||||
},
|
||||
]);
|
||||
const running = reduceWorkflowExecutionSteps(hydrated, {
|
||||
data: {
|
||||
attemptKey: 'node:2',
|
||||
nodeId: 'node',
|
||||
nodeName: '节点',
|
||||
startedAt: 100,
|
||||
},
|
||||
eventId: '2',
|
||||
type: 'node_started',
|
||||
});
|
||||
const finalized = finalizeWorkflowExecutionSteps(running, 'cancelled', 160);
|
||||
|
||||
expect(finalized[0]).toMatchObject({
|
||||
input: { question: '你好' },
|
||||
output: { answer: '你好' },
|
||||
});
|
||||
expect(finalized[1]).toMatchObject({
|
||||
duration: 60,
|
||||
status: 'cancelled',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
export interface WorkflowExecutionTrace {
|
||||
id: string;
|
||||
outcome: 'matched' | 'skipped';
|
||||
targetNodeName: string;
|
||||
}
|
||||
|
||||
export type WorkflowExecutionStepStatus =
|
||||
| 'cancelled'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'running'
|
||||
| 'waiting';
|
||||
|
||||
export interface WorkflowExecutionStepView {
|
||||
attemptKey?: string;
|
||||
duration?: number;
|
||||
endTime?: number;
|
||||
error?: string;
|
||||
hasInput: boolean;
|
||||
hasOutput: boolean;
|
||||
input?: unknown;
|
||||
key: string;
|
||||
nodeClass?: string;
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
output?: unknown;
|
||||
startTime?: number;
|
||||
status: WorkflowExecutionStepStatus;
|
||||
traces: WorkflowExecutionTrace[];
|
||||
}
|
||||
|
||||
interface WorkflowExecutionEvent {
|
||||
data?: Record<string, any>;
|
||||
eventId: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将实时工作流事件归并为稳定的节点执行步骤。
|
||||
*/
|
||||
export function reduceWorkflowExecutionSteps(
|
||||
current: WorkflowExecutionStepView[],
|
||||
event: WorkflowExecutionEvent,
|
||||
now = Date.now(),
|
||||
): WorkflowExecutionStepView[] {
|
||||
if (
|
||||
event.type !== 'node_started' &&
|
||||
event.type !== 'node_finished' &&
|
||||
event.type !== 'node_trace'
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const data = event.data || {};
|
||||
const attemptKey = textValue(data.attemptKey);
|
||||
const nodeId = textValue(data.nodeId);
|
||||
const stepIndex = findStepIndex(current, attemptKey, nodeId);
|
||||
|
||||
if (event.type === 'node_started') {
|
||||
const startTime = numberValue(data.startedAt) ?? now;
|
||||
const nextStep: WorkflowExecutionStepView = {
|
||||
attemptKey: attemptKey || undefined,
|
||||
hasInput: hasOwn(data, 'input'),
|
||||
hasOutput: false,
|
||||
input: data.input,
|
||||
key: attemptKey || `${nodeId || 'node'}:${event.eventId}`,
|
||||
nodeClass: textValue(data.nodeClass) || undefined,
|
||||
nodeId,
|
||||
nodeName: textValue(data.nodeName) || nodeId || '工作流节点',
|
||||
startTime,
|
||||
status: 'running',
|
||||
traces: [],
|
||||
};
|
||||
if (stepIndex === -1) {
|
||||
return [...current, nextStep];
|
||||
}
|
||||
const existingStep = current[stepIndex];
|
||||
if (!existingStep) {
|
||||
return [...current, nextStep];
|
||||
}
|
||||
const next = [...current];
|
||||
next[stepIndex] = {
|
||||
...existingStep,
|
||||
...nextStep,
|
||||
traces: existingStep.traces,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
const existingStep = stepIndex === -1 ? undefined : current[stepIndex];
|
||||
const baseStep =
|
||||
existingStep === undefined
|
||||
? createFallbackStep(event, data, attemptKey, nodeId, now)
|
||||
: existingStep;
|
||||
let updated: WorkflowExecutionStepView;
|
||||
if (event.type === 'node_trace') {
|
||||
const targetNodeName =
|
||||
textValue(data.targetNodeName) ||
|
||||
textValue(data.targetNodeId) ||
|
||||
'后续节点';
|
||||
const outcome = data.outcome === 'matched' ? 'matched' : 'skipped';
|
||||
const trace: WorkflowExecutionTrace = {
|
||||
id: event.eventId,
|
||||
outcome,
|
||||
targetNodeName,
|
||||
};
|
||||
updated = {
|
||||
...baseStep,
|
||||
traces: baseStep.traces.some((item) => item.id === trace.id)
|
||||
? baseStep.traces
|
||||
: [...baseStep.traces, trace],
|
||||
};
|
||||
} else {
|
||||
const endTime = numberValue(data.finishedAt) ?? now;
|
||||
const startTime = baseStep.startTime;
|
||||
updated = {
|
||||
...baseStep,
|
||||
duration:
|
||||
startTime === undefined ? undefined : Math.max(0, endTime - startTime),
|
||||
endTime,
|
||||
error: textValue(data.error) || undefined,
|
||||
hasOutput: hasOwn(data, 'output'),
|
||||
output: data.output,
|
||||
status: resolveLiveStatus(data.status, data.error),
|
||||
};
|
||||
}
|
||||
|
||||
if (stepIndex === -1) {
|
||||
return [...current, updated];
|
||||
}
|
||||
const next = [...current];
|
||||
next[stepIndex] = updated;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将持久化步骤转换为运行详情统一视图。
|
||||
*/
|
||||
export function hydrateWorkflowExecutionSteps(
|
||||
steps: unknown,
|
||||
): WorkflowExecutionStepView[] {
|
||||
if (!Array.isArray(steps)) {
|
||||
return [];
|
||||
}
|
||||
return steps.map((step: Record<string, any>, index) => ({
|
||||
attemptKey: textValue(step.attemptKey) || undefined,
|
||||
duration: numberValue(step.execTime),
|
||||
endTime: timeValue(step.endTime),
|
||||
error: textValue(step.errorInfo) || undefined,
|
||||
hasInput: step.input !== undefined && step.input !== null,
|
||||
hasOutput: step.output !== undefined && step.output !== null,
|
||||
input: parseExecutionValue(step.input),
|
||||
key:
|
||||
textValue(step.attemptKey) ||
|
||||
textValue(step.id) ||
|
||||
`${textValue(step.nodeId) || 'node'}:${index}`,
|
||||
nodeId: textValue(step.nodeId),
|
||||
nodeName:
|
||||
textValue(step.nodeName) || textValue(step.nodeId) || '工作流节点',
|
||||
output: parseExecutionValue(step.output),
|
||||
startTime: timeValue(step.startTime),
|
||||
status: resolvePersistedStatus(step.status),
|
||||
traces: [],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将仍在执行的步骤收口为工作流终态。
|
||||
*/
|
||||
export function finalizeWorkflowExecutionSteps(
|
||||
steps: WorkflowExecutionStepView[],
|
||||
status: 'cancelled' | 'completed' | 'failed',
|
||||
now = Date.now(),
|
||||
): WorkflowExecutionStepView[] {
|
||||
let changed = false;
|
||||
const next = steps.map((step) => {
|
||||
if (step.status !== 'running' && step.status !== 'waiting') {
|
||||
return step;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...step,
|
||||
duration:
|
||||
step.startTime === undefined
|
||||
? step.duration
|
||||
: Math.max(0, now - step.startTime),
|
||||
endTime: now,
|
||||
status,
|
||||
};
|
||||
});
|
||||
return changed ? next : steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化节点输入或输出。
|
||||
*/
|
||||
export function formatExecutionValue(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value ?? null, null, 2);
|
||||
} catch {
|
||||
return String(value ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackStep(
|
||||
event: WorkflowExecutionEvent,
|
||||
data: Record<string, any>,
|
||||
attemptKey: string,
|
||||
nodeId: string,
|
||||
now: number,
|
||||
): WorkflowExecutionStepView {
|
||||
return {
|
||||
attemptKey: attemptKey || undefined,
|
||||
hasInput: false,
|
||||
hasOutput: false,
|
||||
key: attemptKey || `${nodeId || 'node'}:${event.eventId}`,
|
||||
nodeId,
|
||||
nodeName: textValue(data.nodeName) || nodeId || '工作流节点',
|
||||
startTime: now,
|
||||
status: 'running',
|
||||
traces: [],
|
||||
};
|
||||
}
|
||||
|
||||
function findStepIndex(
|
||||
steps: WorkflowExecutionStepView[],
|
||||
attemptKey: string,
|
||||
nodeId: string,
|
||||
) {
|
||||
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
||||
const step = steps[index];
|
||||
if (!step) {
|
||||
continue;
|
||||
}
|
||||
if (attemptKey && step.attemptKey === attemptKey) {
|
||||
return index;
|
||||
}
|
||||
if (!attemptKey && nodeId && step.nodeId === nodeId) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseExecutionValue(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
const text = value.trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLiveStatus(
|
||||
status: unknown,
|
||||
error: unknown,
|
||||
): WorkflowExecutionStepStatus {
|
||||
const normalized = textValue(status).toUpperCase();
|
||||
if (error || normalized === 'ERROR' || normalized === 'FAILED') {
|
||||
return 'failed';
|
||||
}
|
||||
if (normalized === 'SUSPEND') {
|
||||
return 'waiting';
|
||||
}
|
||||
return normalized === 'RUNNING' ? 'running' : 'completed';
|
||||
}
|
||||
|
||||
function resolvePersistedStatus(status: unknown): WorkflowExecutionStepStatus {
|
||||
switch (String(status)) {
|
||||
case '1': {
|
||||
return 'running';
|
||||
}
|
||||
case '5': {
|
||||
return 'waiting';
|
||||
}
|
||||
case '10':
|
||||
case '21': {
|
||||
return 'failed';
|
||||
}
|
||||
default: {
|
||||
return 'completed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasOwn(value: object, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function timeValue(value: unknown) {
|
||||
const direct = numberValue(value);
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? undefined : timestamp;
|
||||
}
|
||||
|
||||
function textValue(value: unknown) {
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
const SUPPORTED_CONTENT_TYPES = new Set([
|
||||
'audio',
|
||||
'image',
|
||||
'other',
|
||||
'text',
|
||||
'video',
|
||||
]);
|
||||
const DEFAULT_FIELD_LABELS = new Set([
|
||||
'下拉字段',
|
||||
'单选字段',
|
||||
'多选字段',
|
||||
'文件字段',
|
||||
'文本字段',
|
||||
'新字段',
|
||||
'长文本字段',
|
||||
]);
|
||||
const GENERATED_FIELD_KEY_PATTERN =
|
||||
/^(?:field_[A-Za-z0-9]+|(?:text|textarea|radio|checkbox|select|file)_field(?:_\d+)?)$/;
|
||||
|
||||
function resolveFieldLabel(field: any) {
|
||||
const key = String(field?.key || '').trim();
|
||||
const label = String(field?.label || '').trim();
|
||||
if (
|
||||
key &&
|
||||
DEFAULT_FIELD_LABELS.has(label) &&
|
||||
!GENERATED_FIELD_KEY_PATTERN.test(key)
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
return label || key;
|
||||
}
|
||||
|
||||
function resolveContentType(
|
||||
type: string,
|
||||
rawContentType: string,
|
||||
parameterContentType: string,
|
||||
) {
|
||||
if (type === 'file') {
|
||||
return 'file';
|
||||
}
|
||||
if (parameterContentType === 'image') {
|
||||
return 'image';
|
||||
}
|
||||
return SUPPORTED_CONTENT_TYPES.has(rawContentType) ? rawContentType : 'text';
|
||||
}
|
||||
|
||||
function resolveDataType(type: string, contentType: string) {
|
||||
if (type === 'checkbox') {
|
||||
return 'Array';
|
||||
}
|
||||
if (contentType === 'file') {
|
||||
return 'File';
|
||||
}
|
||||
if (contentType === 'image') {
|
||||
return 'Object';
|
||||
}
|
||||
return 'String';
|
||||
}
|
||||
|
||||
export function resolveWorkflowFormParameters(workflowParams: any) {
|
||||
const schema = Array.isArray(workflowParams?.startFormSchema)
|
||||
? workflowParams.startFormSchema
|
||||
: [];
|
||||
if (schema.length === 0) {
|
||||
return workflowParams?.parameters || [];
|
||||
}
|
||||
const parameterMap = new Map(
|
||||
(Array.isArray(workflowParams?.parameters)
|
||||
? workflowParams.parameters
|
||||
: []
|
||||
).map((parameter: any) => [
|
||||
String(parameter?.name || '').trim(),
|
||||
parameter,
|
||||
]),
|
||||
);
|
||||
return schema.map((field: any) => {
|
||||
const type = String(field.type || '').trim() || 'text';
|
||||
const rawContentType = String(field.contentType || '').trim();
|
||||
const parameter = parameterMap.get(String(field.key || '').trim()) as
|
||||
| any
|
||||
| undefined;
|
||||
const parameterContentType = String(parameter?.contentType || '').trim();
|
||||
const contentType = resolveContentType(
|
||||
type,
|
||||
rawContentType,
|
||||
parameterContentType,
|
||||
);
|
||||
return {
|
||||
name: field.key,
|
||||
formLabel: resolveFieldLabel(field),
|
||||
formDescription: field.description || '',
|
||||
formPlaceholder: field.placeholder || '',
|
||||
required: Boolean(field.required),
|
||||
defaultValue: field.defaultValue,
|
||||
enums: Array.isArray(field.options) ? field.options : [],
|
||||
contentType,
|
||||
formType: type === 'text' || contentType === 'file' ? 'input' : type,
|
||||
dataType: resolveDataType(type, contentType),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
getWorkflowImagePreviewUrl,
|
||||
normalizeWorkflowImageValue,
|
||||
} from './workflowImageValue';
|
||||
|
||||
/**
|
||||
* 判断附加表单是否存在必填参数。
|
||||
*
|
||||
* @param parameters 附加表单参数
|
||||
* @returns 存在必填参数时返回 true
|
||||
*/
|
||||
export function hasRequiredWorkflowFormParameters(parameters: any[]) {
|
||||
return parameters.some((parameter) => parameter?.required === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户可读的表单填写内容。
|
||||
*
|
||||
* @param parameters 附加表单参数
|
||||
* @param values 表单值
|
||||
* @returns 非空字段组成的多行文本
|
||||
*/
|
||||
export function buildWorkflowFormSubmissionText(
|
||||
parameters: any[],
|
||||
values: Record<string, any>,
|
||||
) {
|
||||
return parameters
|
||||
.map((parameter) => {
|
||||
if (parameter?.contentType === 'image') {
|
||||
return '';
|
||||
}
|
||||
const value = formatWorkflowFormValue(values[parameter?.name]);
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const label = String(
|
||||
parameter?.formLabel || parameter?.name || '补充信息',
|
||||
).trim();
|
||||
return `${label}:${value}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图片表单字段转换为聊天图片附件。
|
||||
*
|
||||
* @param parameters 附加表单参数
|
||||
* @param values 表单值
|
||||
* @returns 可直接渲染的图片附件
|
||||
*/
|
||||
export function buildWorkflowFormSubmissionImages(
|
||||
parameters: any[],
|
||||
values: Record<string, any>,
|
||||
): ChatImageAttachment[] {
|
||||
return parameters.flatMap((parameter) => {
|
||||
if (parameter?.contentType !== 'image') {
|
||||
return [];
|
||||
}
|
||||
const value = values[parameter?.name];
|
||||
const image = normalizeWorkflowImageValue(value);
|
||||
const previewUrl = getWorkflowImagePreviewUrl(value);
|
||||
if (!image || !previewUrl) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
mimeType: image.sourceType === 'url' ? undefined : image.contentType,
|
||||
name:
|
||||
image.sourceType === 'url'
|
||||
? resolveWorkflowImageUrlName(image.url)
|
||||
: image.fileName,
|
||||
previewUrl,
|
||||
size: image.sourceType === 'url' ? undefined : image.size,
|
||||
status: 'ready' as const,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将表单字段值转换为适合会话展示的文本。
|
||||
*
|
||||
* @param value 表单字段值
|
||||
* @returns 用户可读文本;空值返回空字符串
|
||||
*/
|
||||
function formatWorkflowFormValue(value: any): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => formatWorkflowFormValue(item))
|
||||
.filter(Boolean)
|
||||
.join('、');
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '是' : '否';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
for (const key of ['fileName', 'resourceName', 'name', 'url', 'text']) {
|
||||
const displayValue = formatWorkflowFormValue(value[key]);
|
||||
if (displayValue) {
|
||||
return displayValue;
|
||||
}
|
||||
}
|
||||
return Object.keys(value).length > 0 ? '已填写' : '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从图片 URL 中提取用于预览的文件名。
|
||||
*
|
||||
* @param url 图片 URL
|
||||
* @returns 文件名;无法提取时返回“图片”
|
||||
*/
|
||||
function resolveWorkflowImageUrlName(url: string): string {
|
||||
try {
|
||||
const segments = new URL(url).pathname.split('/');
|
||||
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
||||
const segment = segments[index];
|
||||
if (segment) {
|
||||
return decodeURIComponent(segment);
|
||||
}
|
||||
}
|
||||
return '图片';
|
||||
} catch {
|
||||
return '图片';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
export type WorkflowImageSourceType = 'resource' | 'upload' | 'url';
|
||||
|
||||
export type WorkflowImageValue =
|
||||
| {
|
||||
sourceType: 'url';
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
sourceType: 'resource' | 'upload';
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export interface WorkflowImageResourceLike {
|
||||
fileSize?: number | string;
|
||||
resourceName?: string;
|
||||
resourceType?: number | string;
|
||||
resourceUrl?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export const WORKFLOW_IMAGE_LIMITS = {
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
accept: '.png,.jpg,.jpeg,.webp,.gif,.bmp',
|
||||
} as const;
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/bmp',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]);
|
||||
const ALLOWED_EXTENSIONS = new Set(['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']);
|
||||
|
||||
/**
|
||||
* 从上传结果构建单图运行值。
|
||||
*/
|
||||
export function buildWorkflowImageValueFromUpload(
|
||||
file: File,
|
||||
path: string,
|
||||
): WorkflowImageValue {
|
||||
validateWorkflowImageFile(file);
|
||||
const filePath = String(path || '').trim();
|
||||
if (!filePath) {
|
||||
throw new Error('上传结果缺少图片路径');
|
||||
}
|
||||
return {
|
||||
sourceType: 'upload',
|
||||
fileName: file.name,
|
||||
filePath,
|
||||
contentType: file.type || undefined,
|
||||
size: file.size,
|
||||
url: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从素材库对象构建单图运行值。
|
||||
*/
|
||||
export function buildWorkflowImageValueFromResource(
|
||||
resource: WorkflowImageResourceLike,
|
||||
): WorkflowImageValue {
|
||||
const filePath = String(resource?.resourceUrl || '').trim();
|
||||
if (!filePath) {
|
||||
throw new Error('图片素材缺少 resourceUrl');
|
||||
}
|
||||
if (
|
||||
resource.resourceType !== undefined &&
|
||||
Number(resource.resourceType) !== 0
|
||||
) {
|
||||
throw new Error('请选择图片素材');
|
||||
}
|
||||
const suffix = String(resource?.suffix || '').trim().toLowerCase();
|
||||
if (suffix && !ALLOWED_EXTENSIONS.has(suffix)) {
|
||||
throw new Error('仅支持 PNG、JPEG、WebP、GIF、BMP 图片');
|
||||
}
|
||||
const size = toNumber(resource?.fileSize);
|
||||
validateWorkflowImageSize(size);
|
||||
const resourceName = String(resource?.resourceName || '').trim();
|
||||
const fallbackName = filePath.split('/').pop()?.split('?')[0] || 'image';
|
||||
return {
|
||||
sourceType: 'resource',
|
||||
fileName:
|
||||
resourceName && suffix
|
||||
? `${resourceName}.${suffix}`
|
||||
: resourceName || fallbackName,
|
||||
filePath,
|
||||
contentType: suffixToMimeType(suffix),
|
||||
size,
|
||||
url: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTTP/HTTPS URL 构建图片运行值。
|
||||
*/
|
||||
export function buildWorkflowImageValueFromUrl(url: string): WorkflowImageValue {
|
||||
const normalized = String(url || '').trim();
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error('请输入有效的图片 URL');
|
||||
}
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new Error('图片 URL 仅支持 HTTP/HTTPS');
|
||||
}
|
||||
return {
|
||||
sourceType: 'url',
|
||||
url: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化新旧工作流图片值。
|
||||
*/
|
||||
export function normalizeWorkflowImageValue(
|
||||
value: unknown,
|
||||
): WorkflowImageValue | undefined {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
return buildWorkflowImageValueFromUrl(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const filePath = String(candidate.filePath || '').trim();
|
||||
const sourceType = String(
|
||||
candidate.sourceType || (filePath ? 'upload' : 'url'),
|
||||
) as WorkflowImageSourceType;
|
||||
if (sourceType === 'url') {
|
||||
try {
|
||||
return buildWorkflowImageValueFromUrl(String(candidate.url || ''));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (!['resource', 'upload'].includes(sourceType)) {
|
||||
return undefined;
|
||||
}
|
||||
const fileName = String(candidate.fileName || '').trim();
|
||||
if (!fileName || !filePath) {
|
||||
return undefined;
|
||||
}
|
||||
const size = toNumber(candidate.size as number | string | undefined);
|
||||
try {
|
||||
validateWorkflowImageSize(size);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sourceType,
|
||||
fileName,
|
||||
filePath,
|
||||
contentType: String(candidate.contentType || '').trim() || undefined,
|
||||
size,
|
||||
url: String(candidate.url || '').trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断值中是否存在可提交的图片。
|
||||
*/
|
||||
export function hasWorkflowImageValue(value: unknown): boolean {
|
||||
return normalizeWorkflowImageValue(value) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缩略图 URL。
|
||||
*/
|
||||
export function getWorkflowImagePreviewUrl(value: unknown): string {
|
||||
const image = normalizeWorkflowImageValue(value);
|
||||
return image?.sourceType === 'url'
|
||||
? image.url
|
||||
: image?.url || image?.filePath || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验本地图片格式和大小。
|
||||
*/
|
||||
export function validateWorkflowImageFile(file: File) {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (
|
||||
!ALLOWED_MIME_TYPES.has(file.type.toLowerCase()) &&
|
||||
!ALLOWED_EXTENSIONS.has(extension)
|
||||
) {
|
||||
throw new Error('仅支持 PNG、JPEG、WebP、GIF、BMP 图片');
|
||||
}
|
||||
validateWorkflowImageSize(file.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化图片大小。
|
||||
*/
|
||||
export function formatWorkflowImageSize(size?: number): string {
|
||||
if (!size || size <= 0 || Number.isNaN(size)) {
|
||||
return '';
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function validateWorkflowImageSize(size?: number) {
|
||||
if (size !== undefined && size > WORKFLOW_IMAGE_LIMITS.maxSize) {
|
||||
throw new Error('单张图片不能超过 10 MiB');
|
||||
}
|
||||
}
|
||||
|
||||
function suffixToMimeType(suffix: string): string | undefined {
|
||||
if (!suffix) {
|
||||
return undefined;
|
||||
}
|
||||
return suffix === 'jpg' || suffix === 'jpeg'
|
||||
? 'image/jpeg'
|
||||
: `image/${suffix}`;
|
||||
}
|
||||
|
||||
function toNumber(value?: number | string): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ describe('workflow share context', () => {
|
||||
it('reads the share key from a history-mode URL', () => {
|
||||
expect(
|
||||
readWorkflowShareKey(
|
||||
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
|
||||
'https://example.test/share/workflow?shareKey=abc123',
|
||||
),
|
||||
).toBe('abc123');
|
||||
});
|
||||
@@ -21,7 +21,7 @@ describe('workflow share context', () => {
|
||||
it('reads the share key from a hash-mode URL', () => {
|
||||
expect(
|
||||
readWorkflowShareKey(
|
||||
'https://example.test/#/ai/workflow/design?id=1&shareKey=hash-key',
|
||||
'https://example.test/#/share/workflow?shareKey=hash-key',
|
||||
),
|
||||
).toBe('hash-key');
|
||||
});
|
||||
@@ -39,10 +39,9 @@ describe('workflow share context', () => {
|
||||
withWorkflowShareHeader(
|
||||
{ 'Accept-Language': 'zh-CN' },
|
||||
{
|
||||
pageUrl:
|
||||
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
|
||||
pageUrl: 'https://example.test/share/workflow?shareKey=abc123',
|
||||
requestMethod: 'GET',
|
||||
requestUrl: '/api/v1/workflow/detail?id=1',
|
||||
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
@@ -56,9 +55,9 @@ describe('workflow share context', () => {
|
||||
|
||||
expect(
|
||||
withWorkflowShareHeader(headers, {
|
||||
pageUrl: 'https://example.test/ai/workflow/design?id=1',
|
||||
pageUrl: 'https://example.test/share/workflow',
|
||||
requestMethod: 'GET',
|
||||
requestUrl: '/api/v1/workflow/detail?id=1',
|
||||
requestUrl: '/api/v1/workflowChat/descriptor?workflowId=1',
|
||||
}),
|
||||
).toEqual(headers);
|
||||
});
|
||||
@@ -80,17 +79,17 @@ describe('workflow share context', () => {
|
||||
).toEqual({ 'Accept-Language': 'zh-CN' });
|
||||
});
|
||||
|
||||
it('does not reuse an outer share key after entering workflow design', () => {
|
||||
it('does not attach chat sharing capabilities to workflow design', () => {
|
||||
expect(
|
||||
readWorkflowShareKey(
|
||||
'https://example.test/flow/share/knowledge?shareKey=knowledge-key#/ai/workflow/design?id=1',
|
||||
'https://example.test/flow/#/ai/workflow/design?id=1&shareKey=workflow-key',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('only attaches the share key to explicitly allowed workflow requests', () => {
|
||||
const pageUrl =
|
||||
'https://example.test/flow/#/ai/workflow/design?shareKey=workflow-key';
|
||||
'https://example.test/flow/#/share/workflow?shareKey=workflow-key';
|
||||
|
||||
expect(
|
||||
withWorkflowShareHeader(
|
||||
@@ -118,7 +117,7 @@ describe('workflow share context', () => {
|
||||
{
|
||||
pageUrl,
|
||||
requestMethod: 'POST',
|
||||
requestUrl: '/api/v1/workflow/update',
|
||||
requestUrl: '/api/v1/workflowChat/run',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
@@ -128,11 +127,14 @@ describe('workflow share context', () => {
|
||||
|
||||
it('matches only the workflow sharing endpoint whitelist', () => {
|
||||
expect(
|
||||
isWorkflowShareRequest(
|
||||
'/flow/api/v1/workflow/submitPublishApproval',
|
||||
'post',
|
||||
),
|
||||
isWorkflowShareRequest('/flow/api/v1/workflowChat/run', 'post'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkflowShareRequest('/flow/api/v1/workflowChat/execution', 'get'),
|
||||
).toBe(true);
|
||||
expect(isWorkflowShareRequest('/flow/api/v1/workflow/update', 'post')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isWorkflowShareRequest('/flow/api/v1/workflow/page', 'get')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user