feat(XL13): 归档工作流对话运行界面

- 接入发布快照优先与未发布草稿受控运行

- 支持文本和思考流式输出、循环多输出及实时运行详情

- 完成聊天分享、图片输入、中止与清空重来

- 补充后端与前端定向回归测试
This commit is contained in:
2026-07-31 09:40:54 +08:00
parent 048aa9bc1e
commit 615092f4f7
43 changed files with 6128 additions and 242 deletions

View File

@@ -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;
/**

View File

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

View File

@@ -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);

View File

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

View File

@@ -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) => {

View File

@@ -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]"

View File

@@ -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">
支持 PNGJPEGWebPGIFBMP单张不超过 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>

View File

@@ -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: '背景资料',
});
});
});

View File

@@ -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',
},
]);
});
});

View File

@@ -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 图片');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 '图片';
}
}

View File

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

View File

@@ -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,
);

View File

@@ -113,6 +113,12 @@ function getMessageParts(item: ChatTimelineMessageItem) {
];
}
function hasMessageBubble(item: ChatTimelineMessageItem) {
return (
getMessageParts(item).length > 0 || Boolean(item.knowledgeItems?.length)
);
}
function updateThinkingExpanded(partId: string, expanded: boolean) {
const item = messageItem.value;
if (!item) {
@@ -170,26 +176,32 @@ function handleCopyAction() {
:document-loader="documentLoader"
compact
/>
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
<ChatThinkingBlock
v-if="part.type === 'thinking'"
:content="part.content"
:expanded="resolveThinkingExpanded(part)"
:status="part.status"
class="chat-timeline-item__thinking"
readonly
@update:expanded="updateThinkingExpanded(part.id, $event)"
<div
v-if="hasMessageBubble(messageItem)"
class="chat-timeline-item__bubble"
:class="`is-${messageItem.role}`"
>
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
<ChatThinkingBlock
v-if="part.type === 'thinking'"
:content="part.content"
:expanded="resolveThinkingExpanded(part)"
:status="part.status"
class="chat-timeline-item__thinking"
readonly
@update:expanded="updateThinkingExpanded(part.id, $event)"
/>
<ChatTextBlock
v-else
:content="part.content"
:streaming="messageItem.status === 'streaming'"
/>
</template>
<ChatKnowledgeCard
v-if="messageItem.knowledgeItems?.length"
:items="messageItem.knowledgeItems"
/>
<ChatTextBlock
v-else
:content="part.content"
:streaming="messageItem.status === 'streaming'"
/>
</template>
<ChatKnowledgeCard
v-if="messageItem.knowledgeItems?.length"
:items="messageItem.knowledgeItems"
/>
</div>
<ChatMessageToolbar
v-if="showToolbar && messageItem"
:align="messageItem.role === 'user' ? 'end' : 'start'"
@@ -246,18 +258,29 @@ function handleCopyAction() {
display: flex;
flex-direction: column;
gap: 8px;
width: fit-content;
min-width: 0;
max-width: min(78%, 680px);
max-width: 100%;
}
.chat-timeline-item__message {
display: flex;
flex-direction: column;
gap: 8px;
gap: 0;
min-width: 0;
max-width: min(78%, 680px);
}
.chat-timeline-item__message.is-user {
align-items: flex-end;
color: var(--el-text-color-primary);
}
.chat-timeline-item__message.is-assistant,
.chat-timeline-item__message.is-system {
align-items: flex-start;
}
.chat-timeline-item__message :deep(.chat-text-block),
.chat-timeline-item__message :deep(.chat-thinking-block) {
padding: 0;
@@ -271,10 +294,6 @@ function handleCopyAction() {
line-height: 22px;
}
.chat-timeline-item__message.is-user {
color: var(--el-text-color-primary);
}
.chat-timeline-item__message.is-assistant.has-variant-navigator {
width: min(78%, 680px);
}

View File

@@ -40,6 +40,7 @@ const isSeparator = computed(() => props.item.presentation === 'separator');
>
<template #icon>
<BookOpenText
v-if="item.icon !== 'none'"
class="chat-timeline-status-row__icon"
aria-hidden="true"
/>

View File

@@ -73,4 +73,22 @@ describe('ChatTimelineStatusRow', () => {
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true);
expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active');
});
it('can render a plain inline status without the context icon', () => {
const item: ChatTimelineStatusItem = {
icon: 'none',
id: 'workflow-run',
label: '运行完成',
status: 'done',
statusKey: 'workflow-run',
type: 'status',
};
const wrapper = mount(ChatTimelineStatusRow, {
props: { item },
});
expect(wrapper.text()).toContain('运行完成');
expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(false);
expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false);
});
});

View File

@@ -1,8 +1,8 @@
import type {ChatTimelineItem} from '../types';
import type { ChatTimelineItem } from '../types';
import {flushPromises, mount} from '@vue/test-utils';
import { flushPromises, mount } from '@vue/test-utils';
import {afterEach, describe, expect, it, vi} from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import ChatTimeline from '../ChatTimeline.vue';
@@ -27,7 +27,7 @@ function textMessage(
};
}
describe('ChatTimeline toolbar', () => {
describe('chat timeline toolbar', () => {
afterEach(() => {
vi.useRealTimers();
});
@@ -49,6 +49,86 @@ describe('ChatTimeline toolbar', () => {
expect(wrapper.emitted('copyMessage')?.[0]?.[0]).toEqual(userMessage);
});
it('places the user toolbar outside the message bubble', () => {
const wrapper = mount(ChatTimeline, {
props: {
copyable: () => true,
items: [textMessage('user', '用户问题')],
},
});
const message = wrapper.get('.chat-timeline-item__message');
const bubble = message.get('.chat-timeline-item__bubble');
const toolbar = message.get('.chat-message-toolbar');
expect(bubble.find('[aria-label="复制消息"]').exists()).toBe(false);
expect(toolbar.element.parentElement).toBe(message.element);
});
it('renders image and document attachments outside the message bubble', () => {
const wrapper = mount(ChatTimeline, {
props: {
items: [
{
documents: [
{
name: '需求说明.pdf',
status: 'ready',
},
],
id: 'user-attachments',
images: [
{
name: '示例图片.png',
previewUrl: 'data:image/png;base64,AA==',
status: 'ready',
},
],
parts: [],
role: 'user',
status: 'done',
type: 'message',
},
],
},
});
const message = wrapper.get('.chat-timeline-item__message');
expect(message.find('.chat-timeline-item__bubble').exists()).toBe(false);
expect(message.get('.chat-image-attachments').element.parentElement).toBe(
message.element,
);
expect(
message.get('.chat-document-attachments').element.parentElement,
).toBe(message.element);
});
it('keeps only text inside the bubble when attachments accompany text', () => {
const wrapper = mount(ChatTimeline, {
props: {
items: [
textMessage('user', '请识别这张图片', {
images: [
{
name: '示例图片.png',
previewUrl: 'data:image/png;base64,AA==',
status: 'ready',
},
],
}),
],
},
});
const message = wrapper.get('.chat-timeline-item__message');
const bubble = message.get('.chat-timeline-item__bubble');
const images = message.get('.chat-image-attachments');
expect(bubble.text()).toContain('请识别这张图片');
expect(bubble.find('.chat-image-attachments').exists()).toBe(false);
expect(images.element.parentElement).toBe(message.element);
});
it('shows copy and regenerate buttons for assistant messages', async () => {
const assistantMessage = textMessage('assistant', '助手回答', {
regenerable: true,

View File

@@ -128,6 +128,7 @@ export interface ChatTimelineKnowledgeItem extends ChatTimelineItemBase {
}
export interface ChatTimelineStatusItem extends ChatTimelineItemBase {
icon?: 'book' | 'none';
label: string;
presentation?: 'inline' | 'separator';
status: ChatTimelineStatusStatus;

View File

@@ -209,7 +209,7 @@
{/snippet}
<div class="heading">
<Heading level={3}>图片识别</Heading>
<Heading level={3}>图片输入</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId, "images")
}}>
@@ -219,7 +219,11 @@
</Button>
</div>
<RefParameterList dataKeyName="images" noneParameterText="无图片参数" />
<RefParameterList
dataKeyName="images"
noneParameterText="无图片参数"
acceptedContentTypes={["image"]}
/>
{#if queryContextOptions.length > 0}
<Heading level={3} mt="10px">查询数据信息</Heading>