首页工作流表单对话卡片化,历史回显走消息流

选中工作流即以表单卡片推送进入对话消息流,卡片内填写并提交执行,
移除整页表单区。历史 workflow 会话回显为带值表单卡片 + 结果消息,
不再跳整页表单页;回显数据源改用 session/get 记录(requestParams
即完整 flowContent,含 __start__ 值快照),失败判断以 errorMsg 为准。

- 新增 WorkflowFormCard.vue 表单卡片组件(迁移原整页表单渲染/校验逻辑)
- ChatList 渲染 form 类型消息,透传 form-submit
- MainContent 精简为两态(对话页/占位页)
- SessionResults workflow 结果精简摘要,状态以 errorMsg 为准

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-15 20:03:39 +08:00
co-authored by Claude
parent b8d29e31df
commit dc20a6a829
5 changed files with 815 additions and 609 deletions
+32 -4
View File
@@ -9,9 +9,19 @@
v-for="msg in messages"
:key="msg.id"
class="message-row"
:class="{ 'is-user': msg.isUser, 'is-error': !msg.isUser && isErrorMsg(msg) }"
:class="{ 'is-user': msg.isUser, 'is-form': msg.type === 'form', 'is-error': !msg.isUser && isErrorMsg(msg) }"
>
<div class="bubble-wrap">
<!-- 工作流表单卡片不套气泡独立卡片渲染 -->
<WorkflowFormCard
v-if="msg.type === 'form'"
:detail="msg.form"
:readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'"
:submitting="msg.formStatus === 'running'"
:form-error="msg.formError"
@submit="emit('form-submit', msg, $event)"
/>
<template v-else>
<div class="bubble">
<template v-if="msg.isUser">{{ msg.content }}</template>
<template v-else>
@@ -37,15 +47,15 @@
v-html="renderAnswer(msg.content)"
@click="handleAnswerClick"
></div>
<!-- 失败重试 -->
<button v-if="isErrorMsg(msg)" type="button" class="retry-btn" @click="emit('retry', msg)">
<!-- 失败重试工作流结果不提供文本重试需重新提交表单卡片 -->
<button v-if="isErrorMsg(msg) && msg.recordType !== 'workflow'" type="button" class="retry-btn" @click="emit('retry', msg)">
<span class="retry-icon"></span> 重新生成
</button>
</template>
</div>
<!-- AI 消息操作栏hover 显示 -->
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
<button class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
<button v-if="msg.recordType !== 'workflow'" class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
<el-icon><RefreshRight /></el-icon>
</button>
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
@@ -61,6 +71,7 @@
<el-icon><Delete /></el-icon>
</button>
</div>
</template>
<div class="time">{{ msg.time }}</div>
</div>
</div>
@@ -73,6 +84,7 @@ import { ElMessage } from 'element-plus';
import { Delete, DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
import { renderMarkdown } from '../utils/markdown';
import WorkflowFormCard from './WorkflowFormCard.vue';
interface ChatMessage {
id: string;
@@ -86,12 +98,18 @@ interface ChatMessage {
// 后端记录 id / 类型(来自 session/get 的每条结果),存在时才显示删除按钮
recordId?: string;
recordType?: string;
// 工作流表单卡片消息(type==='form' 时渲染 WorkflowFormCard
type?: 'form';
form?: any;
formStatus?: 'editing' | 'running' | 'done' | 'failed';
formError?: string;
}
interface Emits {
(e: 'retry', msg: ChatMessage): void;
(e: 'regenerate', msg: ChatMessage): void;
(e: 'delete', msg: ChatMessage): void;
(e: 'form-submit', msg: ChatMessage, payload: any): void;
(e: 'load-more'): void;
}
@@ -339,6 +357,16 @@ onMounted(() => {
}
}
/* 工作流表单卡片消息:左对齐,卡片自带背景不套气泡 */
.message-row.is-form {
justify-content: flex-start;
.bubble-wrap {
align-items: flex-start;
width: 100%;
.time { padding-left: 2px; }
}
}
/* AI 消息操作栏(hover 显示) */
.msg-actions {
display: flex;
+6 -529
View File
@@ -1,146 +1,9 @@
<template>
<div class="main-content">
<Transition name="content-fade" mode="out-in">
<!-- 工作流动态表单 -->
<div v-if="workflowDetail" :key="'workflow'" class="content-body workflow-form-body">
<div class="workflow-form-card">
<div class="workflow-form-header">
<div>
<h3>{{ workflowDetail.flowName || '工作流表单' }}</h3>
<div class="workflow-form-sub">{{ workflowDetail.description || '请填写以下参数' }}</div>
</div>
</div>
<div class="workflow-form-scroll">
<el-form label-position="top" class="workflow-form">
<template v-if="workflowDetail.nodeInputParams">
<template v-for="node in workflowDetail.nodeInputParams" :key="node.id || node.nodeCode">
<template v-if="hasFormConfig(node)">
<el-form-item
v-for="field in getVisibleFields(node)"
:key="getFieldKey(node, field)"
:label="field.label"
:required="field.required"
>
<!-- 引用其他节点输出只读展示值由上游节点运行时提供 -->
<div v-if="field.valueSource" class="field-source-readonly">
<el-tag type="info" size="small" class="source-tag">引用</el-tag>
<span class="source-text">{{ getSourceDisplay(field) }}自动提供</span>
</div>
<template v-else>
<!-- 文本输入 -->
<el-input
v-if="field.type === 'input' || field.type === 'string'"
v-model="formValues[getFieldKey(node, field)]"
:placeholder="field.required ? '必填' : '选填'"
clearable
/>
<!-- 数字输入 -->
<el-input-number
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
v-model="formValues[getFieldKey(node, field)]"
class="w100"
:controls="true"
:min="field.fieldConstraint?.minValue ?? undefined"
:max="field.fieldConstraint?.maxValue ?? undefined"
/>
<!-- 多行文本 -->
<el-input
v-else-if="field.type === 'textarea'"
v-model="formValues[getFieldKey(node, field)]"
type="textarea"
:rows="3"
:placeholder="field.required ? '必填' : '选填'"
show-word-limit
:maxlength="500"
/>
<!-- 开关 -->
<el-switch
v-else-if="field.type === 'switch'"
v-model="formValues[getFieldKey(node, field)]"
active-text="开启"
inactive-text="关闭"
/>
<!-- 下拉选择 -->
<el-select
v-else-if="field.type === 'select' && field.options"
v-model="formValues[getFieldKey(node, field)]"
:placeholder="field.required ? '必填' : '选填'"
class="w100"
>
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<!-- 文件上传 -->
<div v-else-if="isFileField(field)" class="field-upload-wrapper">
<el-upload
:key="`upload-${getFieldKey(node, field)}`"
:auto-upload="false"
:multiple="field.type === 'uploadMultiple'"
:show-file-list="false"
:accept="getFileAccept(field)"
:on-change="(file: any) => handleFileUpload(node, field, file)"
>
<el-button
size="small"
type="primary"
:loading="uploadingFields[getFieldKey(node, field)]"
:disabled="uploadingFields[getFieldKey(node, field)]"
>
{{ uploadingFields[getFieldKey(node, field)] ? '上传中...' : '选择文件' }}
</el-button>
</el-upload>
<div class="upload-rules">
<span>{{ getFileRuleText(field) }}</span>
<span v-if="getFieldFileList(node, field).length > 0" class="upload-count">
已上传 {{ getFieldFileCountText(node, field) }}
</span>
</div>
<div v-if="getFieldFileList(node, field).length > 0" class="uploaded-files-list">
<div
v-for="(f, fileIdx) in getFieldFileList(node, field)"
:key="fileIdx"
class="uploaded-file-item"
>
<span class="file-name">{{ f.name }}</span>
<el-button type="danger" link size="small" @click="removeFieldFile(node, field, fileIdx)">
删除
</el-button>
</div>
</div>
</div>
<!-- 兜底文本输入 -->
<el-input
v-else
v-model="formValues[getFieldKey(node, field)]"
:placeholder="field.required ? '必填' : '选填'"
/>
</template>
</el-form-item>
</template>
</template>
</template>
</el-form>
<!-- 贴片模板编辑器 -->
<div v-if="currentWorkflowHasPatchLayout" class="patch-template-section">
<PatchTemplateEditor v-model="templates" />
</div>
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
</div>
</div>
</div>
<!-- 对话页 — 无工作流但有消息或会话结果时展示:结果卡片(仅工作流结果时显示)+ 消息流(下)。
<!-- 对话页 有消息或会话结果时展示结果卡片仅工作流结果时显示+ 消息流
key 绑定会话 id切换会话时重建内容区触发 Transition 过渡动画固定 key 会复用 DOM 不触发 -->
<div v-else-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
<div v-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
<SessionResults
v-if="hasWorkflowResults"
:results="results"
@@ -157,6 +20,7 @@
@retry="emit('retry', $event)"
@regenerate="emit('regenerate', $event)"
@delete="emit('delete', $event)"
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
@load-more="emit('load-more', activeHistoryId)"
/>
</div>
@@ -188,11 +52,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { collectHomeFormFields } from '../utils/flowDsl';
import { computed, onMounted, ref } from 'vue';
import { getWorkflowList } from '/@/api/settings/creation';
import { Promotion } from '@element-plus/icons-vue';
import ChatList from './ChatList.vue';
@@ -201,7 +61,6 @@ import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
activeMenu: string;
workflowDetail: any;
activeHistoryId?: string | null;
messages?: any[];
results?: VOSessionInfoResult[];
@@ -215,6 +74,7 @@ interface Emits {
(e: 'regenerate', msg: any): void;
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
(e: 'delete', msg: any): void;
(e: 'form-submit', msg: any, payload: any): void;
(e: 'load-more', sid: string | null | undefined): void;
}
@@ -231,250 +91,7 @@ const emit = defineEmits<Emits>();
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
const hasWorkflowResults = computed(() => Array.isArray(props.results) && props.results.some((r) => r.type === 'workflow'));
const formValues = reactive<Record<string, any>>({});
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
// 上传字段的文件名(与 formValues 平行;单文件为字符串、多文件为数组),执行时随 value 一并写回开始节点传给后端
const formFileNames = reactive<Record<string, string | string[]>>({});
const uploadingFields = reactive<Record<string, boolean>>({});
const templates = ref<any[]>([]);
const getFieldKey = (node: any, field: any): string => {
const id = node.id || node.nodeCode;
return `${id}|${field.path || field.field || field.label}`;
};
const getVisibleFields = (node: any): any[] => {
return collectHomeFormFields(node);
};
// 引用来源展示:valueSource.nodeId 定位上游节点名 + 字段
const getSourceDisplay = (field: any): string => {
const vs = field?.valueSource;
if (!vs || typeof vs !== 'object') return '';
const nodes = props.workflowDetail?.nodeInputParams || [];
const srcNode = nodes.find((n: any) => String(n?.id) === String(vs.nodeId));
const nodeName = srcNode?.name || srcNode?.nodeName || (vs.nodeId ? `节点 ${vs.nodeId}` : '上游节点');
return vs.field ? `${nodeName} · ${vs.field}` : nodeName;
};
const isFileField = (field: any): boolean => {
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
};
const getFileAccept = (field: any): string => {
return field.fieldConstraint?.fileTypes ? '.' + field.fieldConstraint.fileTypes.replace(/,/g, ',.') : '*';
};
const getFileRuleText = (field: any): string => {
const rules: string[] = [];
if (field.fieldConstraint?.fileTypes) rules.push('格式: ' + field.fieldConstraint.fileTypes);
if (field.fieldConstraint?.maxFileSize) rules.push('最大: ' + field.fieldConstraint.maxFileSize + 'MB');
if (field.fieldConstraint?.maxFileCount) rules.push('最多: ' + field.fieldConstraint.maxFileCount + '个');
return rules.join(' | ');
};
const getFieldFileList = (node: any, field: any): { name: string; url: string }[] => {
return fieldFiles[getFieldKey(node, field)] || [];
};
const getFieldConstraint = (field: any): any => {
return field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {};
};
const getFieldFileCountText = (node: any, field: any): string => {
const current = getFieldFileList(node, field).length;
const max = Number(getFieldConstraint(field).maxFileCount);
if (!Number.isNaN(max) && max > 0) {
return `${current} / ${max}`;
}
return `${current}`;
};
const handleFileUpload = async (node: any, field: any, file: any) => {
const raw = file.raw;
if (!raw) return;
const key = getFieldKey(node, field);
const fc = getFieldConstraint(field);
const typeRules = String(fc.fileTypes || '')
.split(',')
.map((t: string) => t.trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean);
if (typeRules.length > 0) {
const ext = (raw.name?.split('.').pop() || '').toLowerCase();
if (!typeRules.includes(ext)) {
ElMessage.warning(`文件格式不符合要求,仅支持:${typeRules.join(', ')}`);
return;
}
}
const maxFileSize = Number(fc.maxFileSize);
if (!Number.isNaN(maxFileSize) && maxFileSize > 0) {
const sizeMB = raw.size / 1024 / 1024;
if (sizeMB > maxFileSize) {
ElMessage.warning(`文件大小超限,最大 ${maxFileSize}MB`);
return;
}
}
const maxFileCount = Number(fc.maxFileCount);
if (!Number.isNaN(maxFileCount) && maxFileCount > 0) {
const currentCount = fieldFiles[key]?.length || 0;
if (currentCount >= maxFileCount) {
ElMessage.warning(`文件数量超限,最多 ${maxFileCount} 个`);
return;
}
}
uploadingFields[key] = true;
try {
const uploadRes = await uploadFile(raw, { timeout: 0 });
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
const fileUrl = uploadRes.data.fileAddressPrefix
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
: uploadRes.data.fileURL;
if (!fieldFiles[key]) fieldFiles[key] = [];
// 文件名取服务器返回 fileName(OSS 存储名,已确认),随 value 一并写回开始节点传给后端
fieldFiles[key].push({ name: uploadRes.data.fileName || raw.name, url: fileUrl });
if (field.type === 'upload') {
formValues[key] = fileUrl;
formFileNames[key] = fieldFiles[key][0]?.name;
} else {
formValues[key] = fieldFiles[key].map((f: any) => f.url);
formFileNames[key] = fieldFiles[key].map((f: any) => f.name);
}
} catch (error: any) {
ElMessage.error(error?.message || '文件上传失败');
} finally {
uploadingFields[key] = false;
}
};
const removeFieldFile = (node: any, field: any, fileIdx: number) => {
const key = getFieldKey(node, field);
if (!fieldFiles[key]) return;
fieldFiles[key].splice(fileIdx, 1);
if (field.type === 'upload') {
formValues[key] = '';
formFileNames[key] = '';
} else {
formValues[key] = fieldFiles[key].map((f) => f.url);
formFileNames[key] = fieldFiles[key].map((f) => f.name);
}
};
const currentWorkflowHasPatchLayout = computed(() => {
const nodes = props.workflowDetail?.nodeInputParams || [];
return nodes.some((node: any) => node.patchLayout === true);
});
const hasFormConfig = (node: any): boolean => {
// 开始节点为唯一表单源(runFormFields 汇总各 model 勾选字段 + form 自定义字段)
return String(node?.nodeCode || '').toLowerCase() === '__start__' && collectHomeFormFields(node).length > 0;
};
const hasFormFields = computed(() => {
if (!props.workflowDetail?.nodeInputParams) return false;
return props.workflowDetail.nodeInputParams.some((node: any) => hasFormConfig(node));
});
const validateFormFields = (): boolean => {
if (!props.workflowDetail?.nodeInputParams) return true;
for (const node of props.workflowDetail.nodeInputParams as any[]) {
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') continue;
const fields = getVisibleFields(node);
for (const field of fields) {
if (!field.required) continue;
// 引用其他节点输出的字段:值由上游节点运行时提供,非用户填写项,跳过必填校验
if (field.valueSource) continue;
const key = getFieldKey(node, field);
const value = formValues[key];
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
ElMessage.warning(`请填写必填项:${field.label}`);
return false;
}
}
}
return true;
};
// 初始化或重置表单
watch(
() => props.workflowDetail,
(detail) => {
Object.keys(formValues).forEach((key) => delete formValues[key]);
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
Object.keys(formFileNames).forEach((key) => delete formFileNames[key]);
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
// 尝试从执行详情恢复贴片模板
const ext = (detail as any)?.extension;
const restoredTemplates = ext?.templates || (detail as any)?.templates || detail?.flowContent?.templates || [];
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
if (!detail?.nodeInputParams) return;
const nodes = detail.nodeInputParams as any[];
// 1) 初始化表单值(model → modelRequestParams runtimeShowform → outputConfig;旧数据 → formConfig
nodes.forEach((node) => {
collectHomeFormFields(node).forEach((field) => {
const key = getFieldKey(node, field);
const hasValue = field.value !== undefined && field.value !== null && field.value !== '';
if (field.type === 'number' || field.type === 'inputNumber') {
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
} else if (field.type === 'switch') {
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
if (field.type === 'fileUpload') {
formValues[key] = hasValue
? Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
: Array.isArray(field.default)
? field.default
: field.default
? [field.default]
: [];
} else {
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
}
} else {
formValues[key] = hasValue ? field.value : (field.default ?? '');
}
});
});
// 2) 文件字段回填已上传文件列表
nodes.forEach((node) => {
collectHomeFormFields(node).forEach((field) => {
if (!isFileField(field)) return;
const key = getFieldKey(node, field);
const rawValue = formValues[key];
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
if (urls.length === 0) return;
// 文件名优先取运行字段携带的服务器名(新数据),旧数据从 url 提取兜底
const rawFn = (field as any).fileName;
fieldFiles[key] = urls.map((url: string, i: number) => ({
name:
(Array.isArray(rawFn) ? rawFn[i] : i === 0 ? rawFn : undefined) ||
String(url || '').split('/').pop() ||
'file-' + Math.random().toString(36).slice(2, 8),
url,
}));
formFileNames[key] =
field.type === 'upload' && fieldFiles[key].length === 1
? fieldFiles[key][0].name
: fieldFiles[key].map((f) => f.name);
});
});
},
{ immediate: true }
);
// 占位页工作流卡片(DeepSeek 式引导:居中标题 + 工作流卡片)
const placeWorkflows = ref<any[]>([]);
const loadPlaceWorkflows = async () => {
@@ -496,8 +113,6 @@ const handlePlaceSelect = (wf: any) => {
onMounted(() => {
loadPlaceWorkflows();
});
defineExpose({ formValues, fieldFiles, formFileNames, templates, validateFormFields });
</script>
<style scoped lang="scss">
@@ -530,144 +145,6 @@ defineExpose({ formValues, fieldFiles, formFileNames, templates, validateFormFie
&::-webkit-scrollbar { display: none; }
}
/* ===== 工作流表单 ===== */
.workflow-form-body {
width: min(880px, calc(100% - 40px));
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
padding: 16px 0;
box-sizing: border-box;
}
.workflow-form-card {
background: #fff;
border: 1px solid #e5e8ee;
border-radius: 14px;
display: flex;
flex-direction: column;
max-height: 100%;
overflow: hidden;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
}
.workflow-form-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f1f5f9;
flex-shrink: 0;
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
.el-button { flex-shrink: 0; }
}
.workflow-form-scroll {
flex: 1;
overflow-y: auto;
padding: 20px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
}
.form-node-group {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 16px;
&:last-child { margin-bottom: 0; }
}
.form-node-group-title {
font-size: 12px;
font-weight: 600;
color: #64748b;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #e2e8f0;
letter-spacing: 0.3px;
text-transform: uppercase;
}
:deep(.el-form-item) {
margin-bottom: 14px;
&:last-child { margin-bottom: 0; }
.el-form-item__label { font-size: 13px; font-weight: 500; color: #334155; padding-bottom: 4px; line-height: 1.5; }
.el-form-item__label::before { color: #ef4444; margin-right: 4px; }
.el-input-number { width: 100%; }
.el-select { width: 100%; }
.el-textarea__inner { font-size: 13px; }
}
:deep(.el-divider) {
border-top-color: #e2e8f0;
margin-top: 6px;
margin-bottom: 12px;
.el-divider__text { font-weight: 600; font-size: 12px; color: #64748b; background: transparent; padding-left: 0; }
}
:deep(.upload-area) {
width: 100%;
.el-upload-dragger {
display: flex; align-items: center; justify-content: center;
width: 100%; height: auto; padding: 20px;
border: 2px dashed #e2e8f0; border-radius: 10px;
background: #fff; cursor: pointer;
transition: all 0.2s;
&:hover { border-color: #93c5fd; background: #f8faff; }
}
.upload-trigger { display: flex; align-items: center; gap: 12px; }
.upload-icon { font-size: 28px; color: #93c5fd; }
.upload-text { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #475569; text-align: left; }
.upload-rule { font-size: 11px; color: #94a3b8; }
&.is-filled .el-upload-dragger { border-color: #bfdbfe; background: #eff6ff; }
}
.uploaded-files-list { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
.uploaded-file-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; background: #fff;
border: 1px solid #e2e8f0; border-radius: 8px;
transition: background 0.15s;
&:hover { background: #f1f5f9; }
.file-type-icon { font-size: 16px; color: #3b82f6; flex-shrink: 0; }
.file-name { flex: 1; font-size: 12px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.el-button { flex-shrink: 0; }
}
.patch-template-section {
margin-top: 16px;
:deep(.patch-template-editor) {
> .el-divider { margin-top: 4px; margin-bottom: 12px; border-top-color: #e2e8f0;
.el-divider__text { font-weight: 600; font-size: 13px; color: #475569; background: transparent; padding-left: 0; }
}
.template-card { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; }
}
}
/* 引用字段只读展示 */
.field-source-readonly {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
color: #64748b;
.source-tag { flex-shrink: 0; }
.source-text { line-height: 1.5; }
}
.w100 { width: 100%; }
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
/* ===== 对话页 ===== */
.chat-body {
width: min(880px, calc(100% - 40px));
@@ -764,4 +241,4 @@ defineExpose({ formValues, fieldFiles, formFileNames, templates, validateFormFie
color: #cbd5e1;
padding: 16px 0;
}
</style>
</style>
+17 -11
View File
@@ -11,11 +11,11 @@
<div v-else-if="results.length === 0" class="results-empty">暂无会话结果</div>
<!-- 结果列表 -->
<div v-else class="results-list">
<div v-for="r in results" :key="r.id" class="result-card" :class="`is-${statusKey(r.status)}`">
<div v-for="r in results" :key="r.id" class="result-card" :class="`is-${statusKey(r)}`">
<div class="result-main">
<span class="result-type">{{ r.type === 'workflow' ? '工作流' : '对话' }}</span>
<span class="result-status" :class="`is-${statusKey(r.status)}`">
<i v-if="r.status === 1" class="status-dot"></i>{{ statusText(r.status) }}
<span class="result-status" :class="`is-${statusKey(r)}`">
<i v-if="!r.errorMsg && r.status === 1" class="status-dot"></i>{{ statusText(r) }}
</span>
<span v-if="r.totalTokens" class="result-meta">{{ r.totalTokens }} tokens</span>
<span v-if="r.totalFee" class="result-meta">¥{{ r.totalFee }}</span>
@@ -73,23 +73,29 @@ const handleScroll = () => {
if (el.scrollHeight - el.scrollTop - el.clientHeight <= 4) emit('load-more');
};
// 状态键:1-运行中, 2-成功, 3-失败
const statusKey = (status: number): string => {
if (status === 2) return 'success';
if (status === 3) return 'failed';
// 状态键:以 errorMsg 为准(部分失败记录 status 仍是 1,仅凭 status 会误判为运行中);
// 无错误时按 status:2-成功, 其余运行中
const statusKey = (r: VOSessionInfoResult): string => {
if (r.errorMsg) return 'failed';
if (r.status === 2) return 'success';
return 'running';
};
const statusText = (status: number): string => {
if (status === 2) return '成功';
if (status === 3) return '失败';
const statusText = (r: VOSessionInfoResult): string => {
if (r.errorMsg) return '失败';
if (r.status === 2) return '成功';
return '运行中';
};
// 卡片问题/参数摘要:chat 取 requestParams.question,其余输出参数 JSON 摘要
// 卡片问题/参数摘要:chat 取 requestParams.questionworkflow 不再平铺 flowContent JSON
// 改为精简节点摘要(完整表单与结果已在消息流中,此处只需让结果卡片保持简洁)
const getQuestionText = (r: VOSessionInfoResult): string => {
const q = r.requestParams?.question;
if (q) return String(q);
if (r.type === 'workflow') {
const nodes = Array.isArray(r.requestParams?.nodes) ? r.requestParams.nodes : [];
return nodes.length > 0 ? `包含 ${nodes.length} 个节点` : '';
}
const params = r.requestParams;
if (params && Object.keys(params).length > 0) {
try {
@@ -0,0 +1,561 @@
<template>
<div class="workflow-form-card" :class="{ readonly, submitting, failed: isFailed }">
<div class="workflow-form-header">
<div>
<h3>{{ detail?.flowName || '工作流表单' }}</h3>
<div class="workflow-form-sub">{{ detail?.description || '请填写以下参数' }}</div>
</div>
</div>
<div class="workflow-form-scroll">
<el-form label-position="top" class="workflow-form">
<template v-if="detail?.nodeInputParams">
<template v-for="node in detail.nodeInputParams" :key="node.id || node.nodeCode">
<template v-if="hasFormConfig(node)">
<el-form-item
v-for="field in getVisibleFields(node)"
:key="getFieldKey(node, field)"
:label="field.label"
:required="field.required"
>
<!-- 引用其他节点输出只读展示值由上游节点运行时提供 -->
<div v-if="field.valueSource" class="field-source-readonly">
<el-tag type="info" size="small" class="source-tag">引用</el-tag>
<span class="source-text">{{ getSourceDisplay(field) }}自动提供</span>
</div>
<template v-else>
<!-- 文本输入 -->
<el-input
v-if="field.type === 'input' || field.type === 'string'"
v-model="formValues[getFieldKey(node, field)]"
:disabled="isDisabled"
:placeholder="field.required ? '必填' : '选填'"
clearable
/>
<!-- 数字输入 -->
<el-input-number
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
v-model="formValues[getFieldKey(node, field)]"
class="w100"
:controls="true"
:disabled="isDisabled"
:min="field.fieldConstraint?.minValue ?? undefined"
:max="field.fieldConstraint?.maxValue ?? undefined"
/>
<!-- 多行文本 -->
<el-input
v-else-if="field.type === 'textarea'"
v-model="formValues[getFieldKey(node, field)]"
type="textarea"
:rows="3"
:disabled="isDisabled"
:placeholder="field.required ? '必填' : '选填'"
show-word-limit
:maxlength="500"
/>
<!-- 开关 -->
<el-switch
v-else-if="field.type === 'switch'"
v-model="formValues[getFieldKey(node, field)]"
:disabled="isDisabled"
active-text="开启"
inactive-text="关闭"
/>
<!-- 下拉选择 -->
<el-select
v-else-if="field.type === 'select' && field.options"
v-model="formValues[getFieldKey(node, field)]"
:disabled="isDisabled"
:placeholder="field.required ? '必填' : '选填'"
class="w100"
>
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<!-- 文件上传 -->
<div v-else-if="isFileField(field)" class="field-upload-wrapper">
<el-upload
:key="`upload-${getFieldKey(node, field)}`"
:auto-upload="false"
:multiple="field.type === 'uploadMultiple'"
:show-file-list="false"
:accept="getFileAccept(field)"
:disabled="isDisabled"
:on-change="(file: any) => handleFileUpload(node, field, file)"
>
<el-button
size="small"
type="primary"
:loading="uploadingFields[getFieldKey(node, field)]"
:disabled="isDisabled || uploadingFields[getFieldKey(node, field)]"
>
{{ uploadingFields[getFieldKey(node, field)] ? '上传中...' : '选择文件' }}
</el-button>
</el-upload>
<div class="upload-rules">
<span>{{ getFileRuleText(field) }}</span>
<span v-if="getFieldFileList(node, field).length > 0" class="upload-count">
已上传 {{ getFieldFileCountText(node, field) }}
</span>
</div>
<div v-if="getFieldFileList(node, field).length > 0" class="uploaded-files-list">
<div
v-for="(f, fileIdx) in getFieldFileList(node, field)"
:key="fileIdx"
class="uploaded-file-item"
>
<span class="file-name">{{ f.name }}</span>
<el-button v-if="!isDisabled" type="danger" link size="small" @click="removeFieldFile(node, field, fileIdx)">
删除
</el-button>
</div>
</div>
</div>
<!-- 兜底文本输入 -->
<el-input
v-else
v-model="formValues[getFieldKey(node, field)]"
:disabled="isDisabled"
:placeholder="field.required ? '必填' : '选填'"
/>
</template>
</el-form-item>
</template>
</template>
</template>
</el-form>
<!-- 贴片模板编辑器 -->
<div v-if="currentWorkflowHasPatchLayout" class="patch-template-section">
<PatchTemplateEditor v-model="templates" />
</div>
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
</div>
<!-- 底部操作区:editing 提交 / running 执行中 / done·failed 定格状态 -->
<div class="workflow-form-footer">
<div v-if="isFailed" class="form-error-text">{{ formError }}</div>
<el-button v-if="submitting" type="primary" loading disabled>执行中...</el-button>
<el-button v-else-if="!readonly" type="primary" :disabled="submitting" @click="handleSubmit">提交执行</el-button>
<el-tag v-else :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { collectHomeFormFields } from '../utils/flowDsl';
interface Props {
// 工作流详情(含 nodeInputParams / flowContent
detail: any;
// 定格只读(done/failed 态):禁用表单与提交
readonly?: boolean;
// 执行中:禁用表单,底部显示加载中
submitting?: boolean;
// 执行失败信息(有值即 failed 态)
formError?: string;
}
interface Emits {
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
submitting: false,
formError: '',
});
const emit = defineEmits<Emits>();
const formValues = reactive<Record<string, any>>({});
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
// 上传字段的文件名(与 formValues 平行;单文件为字符串、多文件为数组),执行时随 value 一并写回开始节点传给后端
const formFileNames = reactive<Record<string, string | string[]>>({});
const uploadingFields = reactive<Record<string, boolean>>({});
const templates = ref<any[]>([]);
// 状态派生:失败 = 携带 formError;禁用 = 定格或执行中
const isFailed = computed(() => !!props.formError);
const isDisabled = computed(() => props.readonly || props.submitting);
const getFieldKey = (node: any, field: any): string => {
const id = node.id || node.nodeCode;
return `${id}|${field.path || field.field || field.label}`;
};
const getVisibleFields = (node: any): any[] => {
return collectHomeFormFields(node);
};
// 引用来源展示:valueSource.nodeId 定位上游节点名 + 字段
const getSourceDisplay = (field: any): string => {
const vs = field?.valueSource;
if (!vs || typeof vs !== 'object') return '';
const nodes = props.detail?.nodeInputParams || [];
const srcNode = nodes.find((n: any) => String(n?.id) === String(vs.nodeId));
const nodeName = srcNode?.name || srcNode?.nodeName || (vs.nodeId ? `节点 ${vs.nodeId}` : '上游节点');
return vs.field ? `${nodeName} · ${vs.field}` : nodeName;
};
const isFileField = (field: any): boolean => {
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
};
const getFileAccept = (field: any): string => {
return field.fieldConstraint?.fileTypes ? '.' + field.fieldConstraint.fileTypes.replace(/,/g, ',.') : '*';
};
const getFileRuleText = (field: any): string => {
const rules: string[] = [];
if (field.fieldConstraint?.fileTypes) rules.push('格式: ' + field.fieldConstraint.fileTypes);
if (field.fieldConstraint?.maxFileSize) rules.push('最大: ' + field.fieldConstraint.maxFileSize + 'MB');
if (field.fieldConstraint?.maxFileCount) rules.push('最多: ' + field.fieldConstraint.maxFileCount + '个');
return rules.join(' | ');
};
const getFieldFileList = (node: any, field: any): { name: string; url: string }[] => {
return fieldFiles[getFieldKey(node, field)] || [];
};
const getFieldConstraint = (field: any): any => {
return field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {};
};
const getFieldFileCountText = (node: any, field: any): string => {
const current = getFieldFileList(node, field).length;
const max = Number(getFieldConstraint(field).maxFileCount);
if (!Number.isNaN(max) && max > 0) {
return `${current} / ${max}`;
}
return `${current}`;
};
const handleFileUpload = async (node: any, field: any, file: any) => {
const raw = file.raw;
if (!raw) return;
const key = getFieldKey(node, field);
const fc = getFieldConstraint(field);
const typeRules = String(fc.fileTypes || '')
.split(',')
.map((t: string) => t.trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean);
if (typeRules.length > 0) {
const ext = (raw.name?.split('.').pop() || '').toLowerCase();
if (!typeRules.includes(ext)) {
ElMessage.warning(`文件格式不符合要求,仅支持:${typeRules.join(', ')}`);
return;
}
}
const maxFileSize = Number(fc.maxFileSize);
if (!Number.isNaN(maxFileSize) && maxFileSize > 0) {
const sizeMB = raw.size / 1024 / 1024;
if (sizeMB > maxFileSize) {
ElMessage.warning(`文件大小超限,最大 ${maxFileSize}MB`);
return;
}
}
const maxFileCount = Number(fc.maxFileCount);
if (!Number.isNaN(maxFileCount) && maxFileCount > 0) {
const currentCount = fieldFiles[key]?.length || 0;
if (currentCount >= maxFileCount) {
ElMessage.warning(`文件数量超限,最多 ${maxFileCount} 个`);
return;
}
}
uploadingFields[key] = true;
try {
const uploadRes = await uploadFile(raw, { timeout: 0 });
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
const fileUrl = uploadRes.data.fileAddressPrefix
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
: uploadRes.data.fileURL;
if (!fieldFiles[key]) fieldFiles[key] = [];
// 文件名取服务器返回 fileName(OSS 存储名,已确认),随 value 一并写回开始节点传给后端
fieldFiles[key].push({ name: uploadRes.data.fileName || raw.name, url: fileUrl });
if (field.type === 'upload') {
formValues[key] = fileUrl;
formFileNames[key] = fieldFiles[key][0]?.name;
} else {
formValues[key] = fieldFiles[key].map((f: any) => f.url);
formFileNames[key] = fieldFiles[key].map((f: any) => f.name);
}
} catch (error: any) {
ElMessage.error(error?.message || '文件上传失败');
} finally {
uploadingFields[key] = false;
}
};
const removeFieldFile = (node: any, field: any, fileIdx: number) => {
const key = getFieldKey(node, field);
if (!fieldFiles[key]) return;
fieldFiles[key].splice(fileIdx, 1);
if (field.type === 'upload') {
formValues[key] = '';
formFileNames[key] = '';
} else {
formValues[key] = fieldFiles[key].map((f) => f.url);
formFileNames[key] = fieldFiles[key].map((f) => f.name);
}
};
const currentWorkflowHasPatchLayout = computed(() => {
const nodes = props.detail?.nodeInputParams || [];
return nodes.some((node: any) => node.patchLayout === true);
});
const hasFormConfig = (node: any): boolean => {
// 开始节点为唯一表单源(runFormFields 汇总各 model 勾选字段 + form 自定义字段)
return String(node?.nodeCode || '').toLowerCase() === '__start__' && collectHomeFormFields(node).length > 0;
};
const hasFormFields = computed(() => {
if (!props.detail?.nodeInputParams) return false;
return props.detail.nodeInputParams.some((node: any) => hasFormConfig(node));
});
const validateFormFields = (): boolean => {
if (!props.detail?.nodeInputParams) return true;
for (const node of props.detail.nodeInputParams as any[]) {
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') continue;
const fields = getVisibleFields(node);
for (const field of fields) {
if (!field.required) continue;
// 引用其他节点输出的字段:值由上游节点运行时提供,非用户填写项,跳过必填校验
if (field.valueSource) continue;
const key = getFieldKey(node, field);
const value = formValues[key];
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
ElMessage.warning(`请填写必填项:${field.label}`);
return false;
}
}
}
return true;
};
// 初始化或重置表单(与旧整页表单一致:含默认值 + 文件回填 + 贴片模板恢复)
watch(
() => props.detail,
(detail) => {
Object.keys(formValues).forEach((key) => delete formValues[key]);
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
Object.keys(formFileNames).forEach((key) => delete formFileNames[key]);
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
// 尝试从执行详情恢复贴片模板
const ext = (detail as any)?.extension;
const restoredTemplates = ext?.templates || (detail as any)?.templates || detail?.flowContent?.templates || [];
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
if (!detail?.nodeInputParams) return;
const nodes = detail.nodeInputParams as any[];
// 1) 初始化表单值(model → modelRequestParams runtimeShowform → outputConfig;旧数据 → formConfig
nodes.forEach((node) => {
collectHomeFormFields(node).forEach((field) => {
const key = getFieldKey(node, field);
const hasValue = field.value !== undefined && field.value !== null && field.value !== '';
if (field.type === 'number' || field.type === 'inputNumber') {
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
} else if (field.type === 'switch') {
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
if (field.type === 'fileUpload') {
formValues[key] = hasValue
? Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
: Array.isArray(field.default)
? field.default
: field.default
? [field.default]
: [];
} else {
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
}
} else {
formValues[key] = hasValue ? field.value : (field.default ?? '');
}
});
});
// 2) 文件字段回填已上传文件列表
nodes.forEach((node) => {
collectHomeFormFields(node).forEach((field) => {
if (!isFileField(field)) return;
const key = getFieldKey(node, field);
const rawValue = formValues[key];
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
if (urls.length === 0) return;
// 文件名优先取运行字段携带的服务器名(新数据),旧数据从 url 提取兜底
const rawFn = (field as any).fileName;
fieldFiles[key] = urls.map((url: string, i: number) => ({
name:
(Array.isArray(rawFn) ? rawFn[i] : i === 0 ? rawFn : undefined) ||
String(url || '').split('/').pop() ||
'file-' + Math.random().toString(36).slice(2, 8),
url,
}));
formFileNames[key] =
field.type === 'upload' && fieldFiles[key].length === 1
? fieldFiles[key][0].name
: fieldFiles[key].map((f) => f.name);
});
});
},
{ immediate: true }
);
const handleSubmit = () => {
if (props.readonly || props.submitting) return;
if (!validateFormFields()) return;
emit('submit', {
formValues: JSON.parse(JSON.stringify(formValues)),
formFileNames: JSON.parse(JSON.stringify(formFileNames)),
templates: JSON.parse(JSON.stringify(templates.value || [])),
});
};
</script>
<style scoped lang="scss">
/* ===== 表单卡片(对话消息流内) ===== */
.workflow-form-card {
background: #fff;
border: 1px solid #e5e8ee;
border-radius: 14px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
width: min(640px, calc(100% - 4px));
max-width: 100%;
transition: opacity 0.2s;
&.readonly {
box-shadow: none;
}
&.submitting {
opacity: 0.9;
}
}
.workflow-form-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f1f5f9;
flex-shrink: 0;
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
}
.workflow-form-scroll {
max-height: 55vh;
overflow-y: auto;
padding: 20px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
}
:deep(.el-form-item) {
margin-bottom: 14px;
&:last-child { margin-bottom: 0; }
.el-form-item__label { font-size: 13px; font-weight: 500; color: #334155; padding-bottom: 4px; line-height: 1.5; }
.el-form-item__label::before { color: #ef4444; margin-right: 4px; }
.el-input-number { width: 100%; }
.el-select { width: 100%; }
.el-textarea__inner { font-size: 13px; }
}
/* 引用字段只读展示 */
.field-source-readonly {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
color: #64748b;
.source-tag { flex-shrink: 0; }
.source-text { line-height: 1.5; }
}
.uploaded-files-list { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
.uploaded-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
.file-name {
flex: 1;
font-size: 12px;
color: #475569;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.patch-template-section {
margin-top: 16px;
:deep(.patch-template-editor) {
> .el-divider { margin-top: 4px; margin-bottom: 12px; border-top-color: #e2e8f0;
.el-divider__text { font-weight: 600; font-size: 13px; color: #475569; background: transparent; padding-left: 0; }
}
.template-card { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; }
}
}
/* 底部操作区 */
.workflow-form-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid #f1f5f9;
background: #fafbfc;
flex-shrink: 0;
.form-error-text {
flex: 1;
font-size: 12px;
color: #ef4444;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.w100 { width: 100%; }
</style>
+199 -65
View File
@@ -16,9 +16,7 @@
/>
<div class="main-wrapper">
<MainContent
ref="mainContentRef"
:active-menu="activeMenu"
:workflow-detail="selectedWorkflowDetail"
:active-history-id="activeHistoryId"
:messages="currentMessages"
:results="currentSessionResults"
@@ -28,6 +26,7 @@
@retry="handleRetry"
@regenerate="handleRetry"
@delete="handleDeleteMessage"
@form-submit="handleFormSubmit"
@workflow-select="handlePlaceWorkflowSelect"
@load-more="handleLoadMore"
/>
@@ -94,7 +93,6 @@ import type { ExecutionTreeItem } from '/@/api/settings/creation';
import {
getExecutionList,
getWorkflowDetail,
getExecutionDetail,
deleteExecutionResult,
downloadToFile,
} from '/@/api/settings/creation';
@@ -121,6 +119,11 @@ interface ChatMessage {
// 后端记录 id / 类型(来自 session/get 的每条结果),用于删除对话记录
recordId?: string;
recordType?: string;
// 工作流表单卡片消息(type==='form' 时渲染 WorkflowFormCard
type?: 'form';
form?: any;
formStatus?: 'editing' | 'running' | 'done' | 'failed';
formError?: string;
}
interface TreeNode {
@@ -358,7 +361,6 @@ const handleSessionModelSaved = async (model: { id: string; modelName: string })
};
const selectedWorkflowDetail = ref<any>(null);
const mainContentRef = ref<any>(null);
const sendingSessions = reactive<Record<string, boolean>>({});
// ===== 会话级长连接(单活跃连接)=====
// 连接状态/helper 见 formatTime/addMessage 之后统一定义;这里声明本轮 handler 与当前连接路由
@@ -373,6 +375,8 @@ interface RoundHandler {
let activeHandler: RoundHandler | null = null;
// 当前活跃的会话级连接(单活跃:切会话时关旧开新)
let wsState: { ws: WebSocket; sid: string; sessionId: string; ready: Promise<WebSocket> } | null = null;
// 当前进行中的一轮类型:true=工作流执行,false=普通对话(sendCancel 协议判断用)
let activeRunIsWorkflow = false;
const isHistoryWorkflow = ref(false);
// 生成中:有活跃会话且该会话正处于发送状态(发送按钮切换为停止按钮)
@@ -436,6 +440,8 @@ const getSessionId = () => {
const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: boolean) => {
if (workflowId === null) {
// 取消选择:移除最新未提交(editing)的表单卡片草稿,已提交/定格的保留
removeDraftFormCard();
selectedWorkflowDetail.value = null;
return;
}
@@ -453,11 +459,60 @@ const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: bool
try {
const res = await getWorkflowDetail(workflowId);
selectedWorkflowDetail.value = res.data || null;
// 对话卡片化:选中工作流即推送一张可填表单卡片进入消息流
if (res.data) pushFormCard(res.data);
} catch {
selectedWorkflowDetail.value = null;
}
};
// 无活跃会话时自动创建虚拟会话(选中工作流 / 发送消息共用),返回当前 sid
const ensureActiveSession = (): { sid: string } => {
if (!activeHistoryId.value) {
const newId = `virtual_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
historyList.value.unshift({
id: newId,
sessionId: newId,
title: '新会话 ' + (historyList.value.length + 1),
time: '刚刚',
});
activeHistoryId.value = newId;
sessionMessages.value.set(newId, []);
}
return { sid: activeHistoryId.value };
};
// 选中工作流:向当前会话消息流推送一张可填表单卡片(对话卡片化)
const pushFormCard = (detail: any) => {
const { sid } = ensureActiveSession();
const msg: ChatMessage = {
id: 'form-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
content: '',
time: formatTime(new Date()),
isUser: false,
type: 'form',
form: detail,
formStatus: 'editing',
};
const list = sessionMessages.value.get(sid);
if (list) list.push(msg);
else sessionMessages.value.set(sid, [msg]);
};
// 取消工作流选择:移除当前会话中最新一条未提交(editing)的表单卡片草稿,已提交的保留
const removeDraftFormCard = () => {
const id = activeHistoryId.value;
if (!id) return;
const list = sessionMessages.value.get(id);
if (!list) return;
for (let i = list.length - 1; i >= 0; i--) {
if (list[i].type === 'form' && list[i].formStatus === 'editing') {
list.splice(i, 1);
return;
}
}
};
// 模板补全保存完成:刷新列表并自动选中新保存的用户工作流
const handleTemplateSaved = async (newId: string) => {
templateDialogVisible.value = false;
@@ -570,7 +625,7 @@ const teardownActiveRound = () => {
const st = wsState;
if (st && st.ws.readyState === WebSocket.OPEN) {
try {
sendCancel(st.ws, !!selectedWorkflowDetail.value);
sendCancel(st.ws, activeRunIsWorkflow);
} catch {
/* 连接异常时忽略,仅做本地定格 */
}
@@ -587,31 +642,9 @@ const teardownSession = () => {
const handleSend = async (message: string) => {
// 无活跃会话时自动创建
if (!activeHistoryId.value) {
const newId = `virtual_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
historyList.value.unshift({
id: newId,
sessionId: newId,
title: '新会话 ' + (historyList.value.length + 1),
time: '刚刚',
});
activeHistoryId.value = newId;
sessionMessages.value.set(newId, []);
}
const sid = activeHistoryId.value;
const { sid } = ensureActiveSession();
if (sendingSessions[sid]) return;
sendingSessions[sid] = true;
const mc = mainContentRef.value;
if (!mc) {
delete sendingSessions[sid];
return;
}
// 工作流模式:发送前校验必填表单字段;普通对话无需表单
if (selectedWorkflowDetail.value && !mc.validateFormFields()) {
delete sendingSessions[sid];
return;
}
// 获取当前会话的 sessionId
const curSession = historyList.value.find((h) => h.id === sid);
@@ -623,7 +656,7 @@ const handleSend = async (message: string) => {
// 添加用户消息
addMessage({
id: 'msg-' + Date.now() + '-user',
content: message || (selectedWorkflowDetail.value ? '执行工作流' : ''),
content: message,
time: formatTime(new Date()),
isUser: true,
});
@@ -632,16 +665,14 @@ const handleSend = async (message: string) => {
const sessionId = curSession.sessionId || getSessionId();
// 分支:有工作流 → 执行工作流;无工作流 → 普通对话
if (selectedWorkflowDetail.value) {
await runWorkflow(sid, sessionId, mc);
} else {
await runChat(sid, sessionId, message);
}
// 工作流执行由表单卡片提交触发(handleFormSubmit);输入框发送恒走普通对话
await runChat(sid, sessionId, message);
};
// ===== 失败重试:重新发送同一条用户消息 =====
const handleRetry = async (msg: ChatMessage) => {
// 工作流结果消息不提供文本重试(重试需重新填写表单卡片提交)
if (msg.recordType === 'workflow') return;
let sid = '';
for (const [k, list] of sessionMessages.value) {
if (list.some((m) => m.id === msg.id)) {
@@ -719,7 +750,18 @@ const handleDeleteMessage = async (msg: ChatMessage) => {
ElMessage.success('已删除');
// 删除成功后重新拉取会话结果,消息流与结果卡片同步
const results = await loadSessionResults(sid);
loadSessionMessagesFromResults(sid, results);
if (Array.isArray(results) && results.some((r) => r.type === 'workflow')) {
// 仍含工作流结果:完整重建消息流(带值表单卡片 + 结果消息)
const session = historyList.value.find((h) => h.id === sid);
if (session) {
isHistoryWorkflow.value = true;
await loadWorkflowSessionMessages(sid, results);
}
} else {
isHistoryWorkflow.value = false;
inputBarRef.value?.clearWorkflow?.();
loadSessionMessagesFromResults(sid, results);
}
} catch {
// 后端已提示错误,保持原状
}
@@ -728,7 +770,7 @@ const handleDeleteMessage = async (msg: ChatMessage) => {
// ===== 停止生成:按执行类型发送对应取消消息并关闭连接,保留已生成内容 =====
// 普通对话走 agent 协议(启动为 {type:'agent'},取消为 {type:'agent_cancel'});
// 工作流走 workflow 协议(取消为 {type:'workflow_cancel'})。
// 判断依据与 handleSend 的分支一致:selectedWorkflowDetail 有值 → 工作流,无值 → 普通对话
// 判断依据activeRunIsWorkflow 标记当前轮类型(runWorkflow/runChat 各自设置)
const handleStopGenerate = () => {
const sid = activeHistoryId.value;
if (!sid || !sendingSessions[sid]) return;
@@ -738,7 +780,13 @@ const handleStopGenerate = () => {
};
// ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 =====
const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
const runWorkflow = async (
sid: string,
sessionId: string,
detail: any,
opts: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[] },
formMsg: ChatMessage
) => {
const curSession = historyList.value.find((h) => h.id === sid);
if (!curSession) {
delete sendingSessions[sid];
@@ -749,6 +797,9 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
const finishExec = (success: boolean, errorMsg?: string) => {
if (finished) return;
finished = true;
// 定格表单卡片:done/failed + 失败信息
formMsg.formStatus = success ? 'done' : 'failed';
formMsg.formError = success ? undefined : errorMsg || '执行失败';
curSession.status = success ? 'completed' : 'failed';
addMessage({
id: 'msg-' + Date.now() + (success ? '-done' : '-fail'),
@@ -804,9 +855,9 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
};
try {
// 1. 构建节点输入参数:深拷贝 DSL,把首页表单值写回开始节点(唯一表单源)运行字段
const nodeInputParams = JSON.parse(JSON.stringify(selectedWorkflowDetail.value.nodeInputParams || []));
applyHomeFormValues(nodeInputParams, mc.formValues, mc.formFileNames);
// 1. 构建节点输入参数:深拷贝 DSL,把表单卡片值写回开始节点(唯一表单源)运行字段
const nodeInputParams = JSON.parse(JSON.stringify(detail.nodeInputParams || []));
applyHomeFormValues(nodeInputParams, opts.formValues, opts.formFileNames);
// 开始节点为唯一表单源:执行时 model 节点不带参数结构、form 节点不带自定义字段
// (值已汇总进开始节点 outputConfig 一并提交,避免重复/冗余参数)
nodeInputParams.forEach((n: any) => {
@@ -821,7 +872,7 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
// 2. 构建 flowContent
const updatedFlowContent = {
...selectedWorkflowDetail.value.flowContent,
...detail.flowContent,
nodes: nodeInputParams,
};
@@ -857,6 +908,9 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
abort: () => {
if (finished) return;
finished = true;
// 停止/切会话:表单卡片定格为已停止(只读),不再追加汇总消息
formMsg.formStatus = 'failed';
formMsg.formError = '执行已停止';
curSession.status = 'completed';
markStopped(sid);
delete sendingSessions[sid];
@@ -870,8 +924,9 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
return;
}
if (activeHandler !== handler) return; // 等待期被终止/切走 → 不再发送启动帧
activeRunIsWorkflow = true;
sendWorkflowStart(ws, {
flowId: selectedWorkflowDetail.value.id,
flowId: detail.id,
flowContent: updatedFlowContent,
});
} catch (e: any) {
@@ -879,8 +934,43 @@ const runWorkflow = async (sid: string, sessionId: string, mc: any) => {
}
};
// ===== 表单卡片提交:工作流执行唯一入口(卡片内按钮触发) =====
const handleFormSubmit = async (
msg: ChatMessage,
payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[] }
) => {
// 定位卡片所在会话
let sid = '';
for (const [k, list] of sessionMessages.value) {
if (list.some((m) => m.id === msg.id)) {
sid = k;
break;
}
}
if (!sid || sendingSessions[sid]) return;
const session = historyList.value.find((h) => h.id === sid);
if (!session || !msg.form) return;
// 卡片定格执行中,禁用重复提交
msg.formStatus = 'running';
msg.formError = undefined;
// 追加用户消息「执行工作流」
addMessage({
id: 'msg-' + Date.now() + '-wfuser',
content: '执行工作流',
time: formatTime(new Date()),
isUser: true,
});
session.status = 'executing';
const sessionId = session.sessionId || getSessionId();
await runWorkflow(sid, sessionId, msg.form, payload, msg);
};
// ===== 普通对话:未选工作流 → 纯问答,AI 回复进消息流 =====
const runChat = async (sid: string, sessionId: string, message: string) => {
activeRunIsWorkflow = false;
// AI loading 占位气泡
const aiMsgId = 'msg-' + Date.now() + '-ai';
addMessage({ id: aiMsgId, content: '', time: formatTime(new Date()), isUser: false, loading: true });
@@ -1133,33 +1223,18 @@ const handleSelectHistory = async (id: string) => {
teardownSession();
activeHistoryId.value = id;
activeMenu.value = 'chat';
// 切换会话不继承工作流选择(回显的锁定标签由 InputBar 单独维护)
selectedWorkflowDetail.value = null;
const session = historyList.value.find((h) => h.id === id);
// 加载会话内结果列表(session/get),用它判断会话类型:普通会话(无 workflow 结果)只依赖这一个接口,不再调 execution/get
const results = await loadSessionResults(id);
const hasWorkflow = Array.isArray(results) && results.some((r) => r.type === 'workflow');
let asForm = false;
if (session && hasWorkflow) {
// 工作流会话:查 execution/get 回显表单(此处逻辑暂保留,后续再优化为用 session/get 数据
try {
const res = await getExecutionDetail(session.id);
if (res.data) {
selectedWorkflowDetail.value = res.data;
isHistoryWorkflow.value = true;
asForm = true;
// 同步回显 InputBar 的工作流选择
const ib = inputBarRef.value as any;
if (ib?.commonWorkflows && res.data.flowName) {
const match = ib.commonWorkflows.find((w: any) => !w.isTemplate && w.name === res.data.flowName);
if (match) ib.selectedWorkflowId = match.id;
}
}
} catch {
// 无执行详情 → 按普通对话会话处理
}
}
if (!asForm) {
// 工作流会话:回显为消息流(带值表单卡片 + 结果消息),不再跳整页表单页
if (hasWorkflow) {
isHistoryWorkflow.value = true; // InputBar 锁定(禁止更换工作流
await loadWorkflowSessionMessages(id, results);
} else {
// 普通对话会话 / 虚拟会话:切回对话页,把 session/get 结果转换为正常对话消息流展示
selectedWorkflowDetail.value = null;
isHistoryWorkflow.value = false;
inputBarRef.value?.clearWorkflow?.();
loadSessionMessagesFromResults(id, results);
@@ -1174,6 +1249,65 @@ const handleSelectHistory = async (id: string) => {
}
};
// 工作流会话回显:把 session/get 结果重建为消息流(带值表单卡片 + 结果消息),
// 表单卡片直接用结果记录的 requestParams(完整 flowContent,含 __start__ outputConfig 值快照)构造,
// 不再依赖 execution/getchat 结果按原逻辑转消息流
const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoResult[]) => {
// 最近一次 workflow 结果记录(results 时间倒序,第一条即最新):它的 requestParams 即回显表单值来源
const latestWf = Array.isArray(results) ? results.find((r) => r.type === 'workflow') : undefined;
// 同步回显 InputBar 的工作流选择(锁定标签展示):按 flowId 匹配(commonWorkflows 的 id 即工作流 id
let flowName = '';
if (latestWf?.flowId) {
const ib = inputBarRef.value as any;
if (ib?.commonWorkflows) {
const match = ib.commonWorkflows.find((w: any) => String(w.id) === String(latestWf.flowId));
if (match) {
ib.selectedWorkflowId = match.id;
flowName = String(match.name || '');
}
}
}
const msgs: ChatMessage[] = [];
// results 时间倒序(新→旧),转正序(旧→新)重建消息流
[...results].reverse().forEach((r) => {
if (r.type === 'chat') {
const q = r.requestParams?.question;
if (q) msgs.push({ id: 'rq-' + r.id, content: String(q), time: r.createdAt || '', isUser: true, recordId: String(r.id), recordType: r.type });
if (r.resultContent) msgs.push({ id: 'ra-' + r.id, content: String(r.resultContent), time: r.createdAt || '', isUser: false, recordId: String(r.id), recordType: r.type });
} else if (r.type === 'workflow') {
// 失败判断:以 errorMsg 为准(部分失败记录 status 仍是 1,仅凭 status 会误判为成功)
const failed = !!r.errorMsg;
// 表单卡片:仅最近一次 workflow 结果渲染带值表单卡片(requestParams.nodes 即 nodeInputParams 结构)
if (r === latestWf && Array.isArray(r.requestParams?.nodes)) {
msgs.push({
id: 'form-' + r.id,
content: '',
time: r.createdAt || '',
isUser: false,
type: 'form',
form: {
flowName,
nodeInputParams: r.requestParams.nodes,
flowContent: r.requestParams,
},
formStatus: failed ? 'failed' : 'done',
formError: failed ? r.errorMsg || '执行失败' : '',
});
}
// 结果消息
msgs.push({
id: failed ? 'wfail-' + r.id : 'wok-' + r.id,
content: failed ? '❌ ' + (r.errorMsg || '执行失败,请重试或联系管理员') : '✅ 执行完成,可前往工作空间查看产出',
time: r.createdAt || '',
isUser: false,
recordId: String(r.id),
recordType: r.type,
});
}
});
sessionMessages.value.set(sid, msgs);
};
// 加载会话内结果(session/get 第一页):workflow+chat 混排,按时间倒序
const loadSessionResults = async (sid: string) => {
if (!sid) return [];