首页工作流表单卡片化闭环:执行进度、输入禁用与取消入口

- socket 节点事件推进表单卡片执行进度,失败详情展示详细原因
- 工作流执行时禁用输入框,取消入口移到卡片 footer,卡片定格可重试
- 工作流 ModelRequestParams 按详情原样透传后端
- 会话内产出文件卡片展示(预览/下载/删除),移除顶部结果卡片区

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-15 21:36:33 +08:00
co-authored by Claude
parent dc20a6a829
commit bb15c053e6
9 changed files with 495 additions and 376 deletions
+22
View File
@@ -19,7 +19,10 @@
:readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'"
:submitting="msg.formStatus === 'running'"
:form-error="msg.formError"
:progress="msg.formProgress"
@submit="emit('form-submit', msg, $event)"
@re-edit="emit('form-re-edit', msg)"
@cancel="emit('form-cancel', msg)"
/>
<template v-else>
<div class="bubble">
@@ -53,6 +56,14 @@
</button>
</template>
</div>
<!-- 工作流执行产出文件卡片挂在结果消息上预览/下载/删除 -->
<WorkflowOutputCard
v-if="msg.outputs && msg.outputs.length"
:outputs="msg.outputs"
@preview="emit('output-preview', msg, $event)"
@download="emit('output-download', msg, $event)"
@delete="emit('output-delete', msg, $event)"
/>
<!-- AI 消息操作栏hover 显示 -->
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
<button v-if="msg.recordType !== 'workflow'" class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
@@ -84,7 +95,9 @@ 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 type { WorkflowOutput } from '../utils/flowDsl';
import WorkflowFormCard from './WorkflowFormCard.vue';
import WorkflowOutputCard from './WorkflowOutputCard.vue';
interface ChatMessage {
id: string;
@@ -103,6 +116,10 @@ interface ChatMessage {
form?: any;
formStatus?: 'editing' | 'running' | 'done' | 'failed';
formError?: string;
// 工作流执行节点进度(后端 node_start/node_complete 事件推进,运行中显示)
formProgress?: { current: number; total: number; nodeName: string };
// 工作流执行产出文件列表(挂在 workflow 结果消息上,渲染产出卡片)
outputs?: WorkflowOutput[];
}
interface Emits {
@@ -110,6 +127,11 @@ interface Emits {
(e: 'regenerate', msg: ChatMessage): void;
(e: 'delete', msg: ChatMessage): void;
(e: 'form-submit', msg: ChatMessage, payload: any): void;
(e: 'form-re-edit', msg: ChatMessage): void;
(e: 'form-cancel', msg: ChatMessage): void;
(e: 'output-preview', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'output-download', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'output-delete', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'load-more'): void;
}
+21 -9
View File
@@ -7,7 +7,7 @@
<div class="selected-tag workflow-tag">
<el-icon><Promotion /></el-icon>
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
<el-icon v-if="!workflowLocked" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
<el-icon v-if="!lockAll" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
</div>
</div>
</Transition>
@@ -17,7 +17,8 @@
v-model="message"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="有什么想聊的?按 Enter 发送,Shift+Enter 换行"
:disabled="generating || isWorkflowRunning"
:placeholder="inputPlaceholder"
class="message-input"
@focus="isFocused = true"
@blur="isFocused = false"
@@ -49,11 +50,11 @@
<span class="hint-text">Shift+Enter 换行</span>
<button
class="send-btn"
:class="{ 'is-stop': generating }"
:disabled="!generating && sendDisabled"
@click="generating ? emit('stop') : handleSend()"
:class="{ 'is-stop': generating && !isWorkflowRunning }"
:disabled="isWorkflowRunning || (!generating && sendDisabled)"
@click="generating && !isWorkflowRunning ? emit('stop') : handleSend()"
>
<el-icon v-if="generating"><VideoPause /></el-icon>
<el-icon v-if="generating && !isWorkflowRunning"><VideoPause /></el-icon>
<el-icon v-else><Top /></el-icon>
</button>
</div>
@@ -92,6 +93,8 @@ interface Props {
generating?: boolean;
// 当前会话模型名称,用于输入栏切换入口展示
currentModelName?: string;
// 工作流正在执行中:输入框/发送按钮整体禁用,停止按钮转移到表单卡片 footer
isWorkflowRunning?: boolean;
}
interface Emits {
@@ -115,6 +118,7 @@ const props = withDefaults(defineProps<Props>(), {
hideShortcuts: false,
generating: false,
currentModelName: '',
isWorkflowRunning: false,
});
const emit = defineEmits<Emits>();
const message = ref('');
@@ -129,6 +133,14 @@ const visibleWorkflows = computed(() => {
return commonWorkflows.value;
});
// 输入框整体锁定:选择了工作流(不可取消)或正处于生成/工作流执行中
const lockAll = computed(() => props.workflowLocked || props.isWorkflowRunning || props.generating);
// 输入框占位符:工作流执行中给出明确提示
const inputPlaceholder = computed(() =>
props.isWorkflowRunning ? '工作流执行中,请稍候...' : '有什么想聊的?按 Enter 发送,Shift+Enter 换行',
);
const fetchWorkflows = async () => {
try {
const res = await getWorkflowList();
@@ -163,7 +175,7 @@ const handleAttachment = () => {
};
const toggleWorkflow = (id: string) => {
if (props.workflowLocked) return;
if (lockAll.value) return;
const item = commonWorkflows.value.find((w) => w.id === id);
const newId = selectedWorkflowId.value === id ? null : id;
selectedWorkflowId.value = newId;
@@ -177,13 +189,13 @@ const resetAll = () => {
};
const clearWorkflow = () => {
if (props.workflowLocked) return;
if (lockAll.value) return;
selectedWorkflowId.value = null;
emit('workflow-select', null);
};
const selectWorkflow = (id: string | null) => {
if (props.workflowLocked) return;
if (lockAll.value) return;
selectedWorkflowId.value = id;
emit('workflow-select', id, false);
};
+12 -22
View File
@@ -1,17 +1,9 @@
<template>
<div class="main-content">
<Transition name="content-fade" mode="out-in">
<!-- 对话页 有消息或会话结果时展示结果卡片工作流结果时显示+ 消息流
<!-- 对话页 有消息或会话结果时展示消息流工作流表单卡片/结果/产出统一在此
key 绑定会话 id切换会话时重建内容区触发 Transition 过渡动画固定 key 会复用 DOM 不触发 -->
<div v-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
<SessionResults
v-if="hasWorkflowResults"
:results="results"
:loading="resultsLoading"
:has-more="hasMore"
:loading-more="loadingMore"
@load-more="emit('load-more', activeHistoryId)"
/>
<ChatList
:messages="messages"
:has-more="hasMore"
@@ -21,6 +13,11 @@
@regenerate="emit('regenerate', $event)"
@delete="emit('delete', $event)"
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
@form-re-edit="(msg: any) => emit('form-re-edit', msg)"
@form-cancel="(msg: any) => emit('form-cancel', msg)"
@output-preview="(msg: any, output: any) => emit('output-preview', msg, output)"
@output-download="(msg: any, output: any) => emit('output-download', msg, output)"
@output-delete="(msg: any, output: any) => emit('output-delete', msg, output)"
@load-more="emit('load-more', activeHistoryId)"
/>
</div>
@@ -56,15 +53,14 @@ import { computed, onMounted, ref } from 'vue';
import { getWorkflowList } from '/@/api/settings/creation';
import { Promotion } from '@element-plus/icons-vue';
import ChatList from './ChatList.vue';
import SessionResults from './SessionResults.vue';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
activeMenu: string;
activeHistoryId?: string | null;
messages?: any[];
// 会话内结果(session/get):仅用于判断「对话页 vs 占位页」,结果渲染统一走消息流
results?: VOSessionInfoResult[];
resultsLoading?: boolean;
hasMore?: boolean;
loadingMore?: boolean;
}
@@ -75,6 +71,11 @@ interface Emits {
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
(e: 'delete', msg: any): void;
(e: 'form-submit', msg: any, payload: any): void;
(e: 'form-re-edit', msg: any): void;
(e: 'form-cancel', msg: any): void;
(e: 'output-preview', msg: any, output: any): void;
(e: 'output-download', msg: any, output: any): void;
(e: 'output-delete', msg: any, output: any): void;
(e: 'load-more', sid: string | null | undefined): void;
}
@@ -82,7 +83,6 @@ const props = withDefaults(defineProps<Props>(), {
activeHistoryId: null,
messages: () => [],
results: () => [],
resultsLoading: false,
hasMore: false,
loadingMore: false,
});
@@ -90,7 +90,6 @@ 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'));
// 占位页工作流卡片(DeepSeek 式引导:居中标题 + 工作流卡片)
const placeWorkflows = ref<any[]>([]);
@@ -159,15 +158,6 @@ onMounted(() => {
&::-webkit-scrollbar { display: none; }
}
/* 会话结果区块:固定在消息流上方,结果多时内部滚动(不挤压消息流) */
.chat-body :deep(.session-results) {
flex-shrink: 0;
max-height: 42%;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
/* 消息流填充剩余高度并在内部滚动 */
.chat-body :deep(.chat-list-scroll) {
flex: 1;
@@ -1,288 +0,0 @@
<template>
<div ref="resultsRef" class="session-results" @scroll.passive="handleScroll">
<div class="results-header">
<span class="results-title">会话结果</span>
<span v-if="results.length" class="results-count">{{ results.length }}</span>
</div>
<!-- 加载中 -->
<div v-if="loading && results.length === 0" class="results-empty">加载中</div>
<!-- 空态 -->
<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)}`">
<div class="result-main">
<span class="result-type">{{ r.type === 'workflow' ? '工作流' : '对话' }}</span>
<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>
<span class="result-time">{{ r.createdAt || '' }}</span>
</div>
<!-- 问题 / 参数摘要 -->
<div v-if="getQuestionText(r)" class="result-question">{{ getQuestionText(r) }}</div>
<!-- AI 回复resultContent 字段即回复文本直接展示 -->
<div v-if="r.resultContent" class="result-reply">
<div class="result-reply-text">{{ r.resultContent }}</div>
</div>
<div v-if="r.errorMsg" class="result-sub">
<span class="result-error"> {{ r.errorMsg }}</span>
</div>
</div>
</div>
<!-- 底部有更多结果时滚动加载 -->
<div v-if="hasMore || loadingMore" class="results-load-more">
{{ loadingMore ? '加载中' : '继续下滚加载更早的结果' }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
results: VOSessionInfoResult[];
loading?: boolean;
hasMore?: boolean;
loadingMore?: boolean;
}
interface Emits {
(e: 'load-more'): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
hasMore: false,
loadingMore: false,
});
const emit = defineEmits<Emits>();
const resultsRef = ref<HTMLElement | null>(null);
// 滚动到底部时触发加载更早结果(防重入由父组件 sessionLoadingMore 保证)
const handleScroll = () => {
const el = resultsRef.value;
if (!el || !props.hasMore || props.loadingMore) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight <= 4) emit('load-more');
};
// 状态键:以 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 = (r: VOSessionInfoResult): string => {
if (r.errorMsg) return '失败';
if (r.status === 2) return '成功';
return '运行中';
};
// 卡片问题/参数摘要: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 {
return JSON.stringify(params);
} catch {
return '';
}
}
return '';
};
</script>
<style scoped lang="scss">
.session-results {
width: 100%;
max-width: 880px;
margin: 0 auto;
padding: 12px 0 4px;
flex-shrink: 0;
}
.results-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.results-title {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.4px;
}
.results-count {
font-size: 11px;
color: #cbd5e1;
}
.results-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.results-empty {
font-size: 12px;
color: #cbd5e1;
padding: 8px 0;
}
.results-load-more {
font-size: 11px;
color: #cbd5e1;
text-align: center;
padding: 8px 0 4px;
}
.result-card {
background: #fff;
border: 1px solid #eef0f3;
border-radius: 10px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 4px;
transition: border-color 0.15s, box-shadow 0.15s;
&.is-running {
border-color: #dbeafe;
background: #f8fbff;
}
&.is-failed {
border-color: #fee2e2;
background: #fef6f6;
}
}
.result-main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.result-type {
font-size: 11px;
font-weight: 600;
color: #64748b;
background: #f1f5f9;
border-radius: 4px;
padding: 1px 6px;
}
.result-status {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
&.is-running {
color: #2563eb;
}
&.is-success {
color: #16a34a;
}
&.is-failed {
color: #dc2626;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2563eb;
animation: status-pulse 1.2s ease-in-out infinite;
}
}
@keyframes status-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.result-meta {
font-size: 11px;
color: #94a3b8;
}
.result-time {
font-size: 11px;
color: #cbd5e1;
margin-left: auto;
}
.result-sub {
display: flex;
align-items: center;
gap: 10px;
}
.result-error {
font-size: 12px;
color: #dc2626;
flex: 1;
word-break: break-all;
}
.result-error {
font-size: 12px;
color: #dc2626;
word-break: break-all;
}
.result-question {
font-size: 13px;
font-weight: 600;
color: #1e293b;
padding: 2px 0;
word-break: break-all;
}
/* AI 回复区(txt 内容) */
.result-reply {
margin-top: 2px;
}
.result-reply-text {
font-size: 13px;
color: #334155;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 8px 10px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
}
</style>
+64 -4
View File
@@ -141,11 +141,23 @@
<!-- 底部操作区: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>
<!-- 执行中:节点文本进度推进(后端 node_start/node_complete 事件),无进度时兜底「执行中...」 -->
<!-- 取消执行:工作流执行中的终止入口(InputBar 停止按钮对工作流禁用,改由卡片 footer 提供) -->
<div v-if="submitting" class="executing-progress">
<span class="exec-spinner" />
<span class="exec-text">{{ progressText }}</span>
<el-button size="small" class="exec-cancel-btn" @click="emit('cancel')">取消执行</el-button>
</div>
<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>
<template v-else>
<el-tag :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<!-- 失败卡片可「重新编辑并执行」:保留已填值切回可编辑,改完重新提交 -->
<el-button v-if="isFailed" size="small" @click="emit('re-edit')">
<el-icon><RefreshRight /></el-icon>重新编辑并执行
</el-button>
</template>
</div>
</div>
</template>
@@ -153,6 +165,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { RefreshRight } from '@element-plus/icons-vue';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { collectHomeFormFields } from '../utils/flowDsl';
@@ -166,16 +179,23 @@ interface Props {
submitting?: boolean;
// 执行失败信息(有值即 failed 态)
formError?: string;
// 执行进度(后端 node_start/node_complete 事件推进):当前节点序 / 总节点数 / 节点名
progress?: { current: number; total: number; nodeName: string };
}
interface Emits {
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
// 失败卡片「重新编辑并执行」:由父组件把 formStatus 切回 editing,保留已填值
(e: 're-edit'): void;
// 执行中取消:工作流取消入口(InputBar 停止按钮对工作流禁用)
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
submitting: false,
formError: '',
progress: () => ({ current: 0, total: 0, nodeName: '' }),
});
const emit = defineEmits<Emits>();
@@ -190,6 +210,16 @@ const templates = ref<any[]>([]);
const isFailed = computed(() => !!props.formError);
const isDisabled = computed(() => props.readonly || props.submitting);
// 执行进度文本:有节点进度(current/total>0)显示「正在执行 N/M:节点名」,否则兜底「执行中...」
const progressText = computed(() => {
const p = props.progress;
if (p && p.total > 0 && p.current > 0) {
const name = p.nodeName ? `${p.nodeName}` : '';
return `正在执行 ${p.current}/${p.total}${name}...`;
}
return '执行中...';
});
const getFieldKey = (node: any, field: any): string => {
const id = node.id || node.nodeCode;
return `${id}|${field.path || field.field || field.label}`;
@@ -551,10 +581,40 @@ const handleSubmit = () => {
font-size: 12px;
color: #ef4444;
line-height: 1.5;
white-space: pre-wrap; /* 失败详情可能含换行(error 字段多行原因),允许换行展示 */
word-break: break-word;
}
}
/* 执行进度:spinner + 文本,节点事件推进 */
.executing-progress {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
.exec-spinner {
width: 14px;
height: 14px;
flex-shrink: 0;
border: 2px solid #bfdbfe;
border-top-color: #2563eb;
border-radius: 50%;
animation: exec-spin 0.8s linear infinite;
}
.exec-text {
font-size: 13px;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exec-cancel-btn {
margin-left: auto;
flex-shrink: 0;
}
}
@keyframes exec-spin {
to { transform: rotate(360deg); }
}
.w100 { width: 100%; }
@@ -0,0 +1,148 @@
<template>
<div class="workflow-output-card">
<div v-for="(out, i) in outputs" :key="i" class="output-item">
<el-icon class="output-icon" :class="`is-${out.type || 'file'}`">
<component :is="getIcon(out.type)" />
</el-icon>
<span class="output-name" :title="out.name">{{ out.name }}</span>
<span class="output-actions">
<button v-if="out.type !== 'text'" type="button" class="output-action" title="预览" @click="emit('preview', out)">
<el-icon><View /></el-icon>
</button>
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
<el-icon><Download /></el-icon>
</button>
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
<el-icon><Delete /></el-icon>
</button>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { Picture, VideoPlay, Headset, Document, View, Download, Delete } from '@element-plus/icons-vue';
// 产出文件:url 文件地址(相对/绝对)、name 展示名、type 预览类型(image/video/audio/text/file)、backendId 执行记录 id
interface WorkflowOutput {
url: string;
name: string;
type: string;
backendId: string;
}
interface Props {
outputs: WorkflowOutput[];
}
interface Emits {
(e: 'preview', output: WorkflowOutput): void;
(e: 'download', output: WorkflowOutput): void;
(e: 'delete', output: WorkflowOutput): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
// 按文件类型选图标(与工作空间侧边栏一致)
const getIcon = (type: string) => {
const t = String(type || '').toLowerCase();
if (t === 'image') return Picture;
if (t === 'video') return VideoPlay;
if (t === 'audio') return Headset;
return Document;
};
</script>
<style scoped lang="scss">
.workflow-output-card {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 10px;
padding: 8px;
}
.output-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
transition: background 0.15s;
&:hover {
background: #eef2f7;
.output-actions {
opacity: 1;
}
}
}
.output-icon {
font-size: 16px;
flex-shrink: 0;
&.is-image {
color: #2563eb;
}
&.is-video {
color: #7c3aed;
}
&.is-audio {
color: #d97706;
}
&.is-file,
&.is-text {
color: #64748b;
}
}
.output-name {
flex: 1;
font-size: 13px;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.output-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.output-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
color: #94a3b8;
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: #e2e8f0;
color: #475569;
}
&.is-danger:hover {
background: #fef2f2;
color: #dc2626;
}
.el-icon {
font-size: 13px;
}
}
</style>
+152 -51
View File
@@ -20,13 +20,17 @@
:active-history-id="activeHistoryId"
:messages="currentMessages"
:results="currentSessionResults"
:results-loading="currentSessionResultsLoading"
:has-more="currentSessionHasMore"
:loading-more="currentSessionLoadingMore"
@retry="handleRetry"
@regenerate="handleRetry"
@delete="handleDeleteMessage"
@form-submit="handleFormSubmit"
@form-re-edit="handleFormReEdit"
@form-cancel="handleFormCancel"
@output-preview="handleOutputPreview"
@output-download="handleOutputDownload"
@output-delete="handleOutputDelete"
@workflow-select="handlePlaceWorkflowSelect"
@load-more="handleLoadMore"
/>
@@ -36,6 +40,7 @@
:workflow-locked="isHistoryWorkflow"
:hide-shortcuts="isPlaceholder"
:generating="isGenerating"
:is-workflow-running="isWorkflowRunning"
:current-model-name="currentChatModelName"
@send="handleSend"
@workflow-select="handleWorkflowSelect"
@@ -85,14 +90,16 @@ import MainContent from './components/MainContent.vue';
import InputBar from './components/InputBar.vue';
import TemplateCompleteDialog from './components/TemplateCompleteDialog.vue';
import SessionModelSetter from './components/SessionModelSetter.vue';
import { applyHomeFormValues } from './utils/flowDsl';
import { applyHomeFormValues, extractWorkflowOutputs } from './utils/flowDsl';
import type { WorkflowOutput } from './utils/flowDsl';
import { getChatModel, listModelManage } from '/@/api/settings/modelConfigV2';
import { connectSessionSocket, sendAgentStart, sendWorkflowStart, sendCancel } from './utils/wsExecute';
import { parseWsMessage, getDelta, getAnswer, getErrorText, getToolCallName, getToolResultText } from './utils/wsMessage';
import { parseWsMessage, getDelta, getAnswer, getErrorText, getNodeProgress, getToolCallName, getToolResultText } from './utils/wsMessage';
import type { ExecutionTreeItem } from '/@/api/settings/creation';
import {
getExecutionList,
getWorkflowDetail,
getExecutionDetail,
deleteExecutionResult,
downloadToFile,
} from '/@/api/settings/creation';
@@ -124,6 +131,10 @@ interface ChatMessage {
form?: any;
formStatus?: 'editing' | 'running' | 'done' | 'failed';
formError?: string;
// 工作流执行节点进度(后端 node_start/node_complete 事件推进,运行中显示)
formProgress?: { current: number; total: number; nodeName: string };
// 工作流执行产出文件列表(挂在结果消息上,渲染产出卡片)
outputs?: WorkflowOutput[];
}
interface TreeNode {
@@ -376,11 +387,14 @@ let activeHandler: RoundHandler | null = null;
// 当前活跃的会话级连接(单活跃:切会话时关旧开新)
let wsState: { ws: WebSocket; sid: string; sessionId: string; ready: Promise<WebSocket> } | null = null;
// 当前进行中的一轮类型:true=工作流执行,false=普通对话(sendCancel 协议判断用)
let activeRunIsWorkflow = false;
// ref 响应式:InputBar 依据它区分「工作流执行中」与「普通对话生成中」,决定禁用/停止按钮展示
const activeRunIsWorkflow = ref(false);
const isHistoryWorkflow = ref(false);
// 生成中:有活跃会话且该会话正处于发送状态(发送按钮切换为停止按钮)
const isGenerating = computed(() => !!activeHistoryId.value && !!sendingSessions[activeHistoryId.value]);
// 工作流执行中:isGenerating 且本轮为工作流 → 输入框整体禁用,停止按钮转移到表单卡片 footer
const isWorkflowRunning = computed(() => isGenerating.value && activeRunIsWorkflow.value);
const inputBarRef = ref<any>(null);
const templateDialogVisible = ref(false);
const pendingTemplate = ref<any>(null);
@@ -413,10 +427,6 @@ const currentSessionResults = computed(() => {
const id = activeHistoryId.value;
return id ? sessionResultsMap[id] || [] : [];
});
const currentSessionResultsLoading = computed(() => {
const id = activeHistoryId.value;
return !!id && !!sessionResultsLoading[id];
});
// session/get 分页:打开会话详情默认只拿最近 10 条对话(约一屏),更早的通过滚动加载更多(下一页更早的记录)
const SESSION_PAGE_SIZE = 10;
@@ -440,7 +450,21 @@ const getSessionId = () => {
const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: boolean) => {
if (workflowId === null) {
// 取消选择:移除最新未提交(editing)的表单卡片草稿,已提交/定格的保留
// 取消选择:若存在未提交(editing)的表单草稿先确认是否放弃,已提交/定格的保留
const id = activeHistoryId.value;
const list = id ? sessionMessages.value.get(id) : undefined;
const hasDraft = !!list && list.some((m) => m.type === 'form' && m.formStatus === 'editing');
if (hasDraft) {
try {
await ElMessageBox.confirm('放弃当前未提交的工作流表单?', '提示', {
confirmButtonText: '放弃',
cancelButtonText: '继续编辑',
type: 'warning',
});
} catch {
return; // 用户选择继续编辑,不清除草稿与选择
}
}
removeDraftFormCard();
selectedWorkflowDetail.value = null;
return;
@@ -457,6 +481,8 @@ const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: bool
return;
}
try {
// 切换工作流:先移除当前会话未提交(editing)的旧草稿卡片,已提交/定格的保留
removeDraftFormCard();
const res = await getWorkflowDetail(workflowId);
selectedWorkflowDetail.value = res.data || null;
// 对话卡片化:选中工作流即推送一张可填表单卡片进入消息流
@@ -625,7 +651,7 @@ const teardownActiveRound = () => {
const st = wsState;
if (st && st.ws.readyState === WebSocket.OPEN) {
try {
sendCancel(st.ws, activeRunIsWorkflow);
sendCancel(st.ws, activeRunIsWorkflow.value);
} catch {
/* 连接异常时忽略,仅做本地定格 */
}
@@ -797,16 +823,20 @@ const runWorkflow = async (
const finishExec = (success: boolean, errorMsg?: string) => {
if (finished) return;
finished = true;
// 定格表单卡片:done/failed + 失败信息
// 定格表单卡片:done/failed + 失败信息;执行结束清进度(避免残留影响重新编辑)
formMsg.formStatus = success ? 'done' : 'failed';
formMsg.formError = success ? undefined : errorMsg || '执行失败';
formMsg.formProgress = undefined;
curSession.status = success ? 'completed' : 'failed';
addMessage({
// 结果消息:成功记录后续异步附加本次执行产出(outputs)
const resultMsg: ChatMessage = {
id: 'msg-' + Date.now() + (success ? '-done' : '-fail'),
content: success ? '✅ 执行完成,可前往工作空间查看产出' : `${errorMsg || '执行失败,请重试或联系管理员'}`,
time: formatTime(new Date()),
isUser: false,
});
outputs: [],
};
addMessage(resultMsg);
if (success) {
ElMessage.success('✅ 执行完成,可前往工作空间查看');
// 刷新工作空间树(查询所有结果)
@@ -814,6 +844,19 @@ const runWorkflow = async (
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
treeNodes.value = buildTreeNodes(res.data?.tree || []);
});
// 会话内渲染本次产出:拉最新 session/get 记录(时间倒序首条)→ getExecutionDetail → 附加到结果消息
// 时序降级:记录未及时写入则静默跳过(用户重进会话可见回显产出)
loadSessionResults(sid).then((results) => {
const latest = Array.isArray(results) && results.length ? results[0] : undefined;
if (latest && latest.type === 'workflow' && !latest.errorMsg) {
getExecutionDetail(String(latest.id))
.then((res) => {
const outputs = extractWorkflowOutputs(res?.data, String(latest.id));
if (outputs.length) resultMsg.outputs = outputs;
})
.catch(() => {});
}
});
} else {
ElMessage.error(errorMsg || '执行失败,请重试');
}
@@ -856,19 +899,10 @@ const runWorkflow = async (
try {
// 1. 构建节点输入参数:深拷贝 DSL,把表单卡片值写回开始节点(唯一表单源)运行字段
// 其余节点结构原样透传后端——后端执行依赖 model 节点的 modelConfig(含 modelRequestParams 等),
// 从详情拿到的工作流是什么样就给后端什么样,不做任何字段删除。
const nodeInputParams = JSON.parse(JSON.stringify(detail.nodeInputParams || []));
applyHomeFormValues(nodeInputParams, opts.formValues, opts.formFileNames);
// 开始节点为唯一表单源:执行时 model 节点不带参数结构、form 节点不带自定义字段
// (值已汇总进开始节点 outputConfig 一并提交,避免重复/冗余参数)
nodeInputParams.forEach((n: any) => {
const code = String(n?.nodeCode || '').toLowerCase();
if (code === 'model' && n?.modelConfig && typeof n.modelConfig === 'object') {
delete n.modelConfig.modelRequestParams;
delete n.modelConfig.modelFormFields;
} else if (code === 'form') {
delete n.outputConfig;
}
});
// 2. 构建 flowContent
const updatedFlowContent = {
@@ -888,6 +922,10 @@ const runWorkflow = async (
finishExec(false, getErrorText(msg));
} else if (msg.type === 'answer') {
finishExec(true);
} else {
// 工作流节点进度推进:node_start / node_complete → 更新表单卡片运行态进度文本
const prog = getNodeProgress(msg);
if (prog) formMsg.formProgress = prog;
}
return;
}
@@ -908,11 +946,11 @@ const runWorkflow = async (
abort: () => {
if (finished) return;
finished = true;
// 停止/切会话:表单卡片定格为已停止(只读),不再追加汇总消息
// 取消/切会话:表单卡片定格为已取消(只读、保留已填值),不再追加汇总消息
formMsg.formStatus = 'failed';
formMsg.formError = '执行已停止';
formMsg.formError = '执行已取消';
formMsg.formProgress = undefined;
curSession.status = 'completed';
markStopped(sid);
delete sendingSessions[sid];
},
};
@@ -924,7 +962,7 @@ const runWorkflow = async (
return;
}
if (activeHandler !== handler) return; // 等待期被终止/切走 → 不再发送启动帧
activeRunIsWorkflow = true;
activeRunIsWorkflow.value = true;
sendWorkflowStart(ws, {
flowId: detail.id,
flowContent: updatedFlowContent,
@@ -951,26 +989,75 @@ const handleFormSubmit = async (
const session = historyList.value.find((h) => h.id === sid);
if (!session || !msg.form) return;
// 卡片定格执行中,禁用重复提交
// 标记本轮发送中:工作流执行期间 InputBar 显示停止按钮(isGenerating 依赖 sendingSessions
sendingSessions[sid] = true;
// 卡片定格执行中,禁用重复提交;表单卡片 running 态即提交意图,不追加用户消息
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);
};
// 失败卡片「重新编辑并执行」:切回 editing 保留已填值(组件内 formValues 随消息保留,
// detail 未变不触发重置),改完重新提交
const handleFormReEdit = (msg: ChatMessage) => {
msg.formStatus = 'editing';
msg.formError = undefined;
};
// 工作流执行中取消(卡片 footer「取消执行」):复用 teardownActiveRound —— 发 cancel 帧 + 本轮 abort
// abort 把卡片定格为「执行已取消」(保留已填值,可重新编辑并执行)
const handleFormCancel = (_msg: ChatMessage) => {
teardownActiveRound();
};
// ===== 会话内产出文件卡片操作:预览 / 下载 / 删除 =====
const handleOutputPreview = (msg: ChatMessage, output: WorkflowOutput) => {
if (!output.url) return;
previewUrl.value = buildAssetUrl(output.url);
previewMode.value = getPreviewMode(output.type);
previewDialogVisible.value = true;
};
const handleOutputDownload = async (msg: ChatMessage, output: WorkflowOutput) => {
if (!output.url) return;
await downloadAsset(output.url, output.name);
};
const handleOutputDelete = async (msg: ChatMessage, output: WorkflowOutput) => {
try {
await ElMessageBox.confirm(`确定删除产出「${output.name}」?此操作不可恢复。`, '删除产出', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
});
} catch {
return;
}
try {
if (output.backendId && output.url) {
await deleteExecutionResult({ id: output.backendId, content: output.url });
}
// 从消息产出列表移除,并刷新工作空间树
if (Array.isArray(msg.outputs)) {
const idx = msg.outputs.findIndex((o) => o.url === output.url);
if (idx >= 0) msg.outputs.splice(idx, 1);
}
getExecutionList().then((res) => {
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
treeNodes.value = buildTreeNodes(res.data?.tree || []);
});
ElMessage.success('已删除');
} catch (e: any) {
ElMessage.error(e?.message || '删除失败');
}
};
// ===== 普通对话:未选工作流 → 纯问答,AI 回复进消息流 =====
const runChat = async (sid: string, sessionId: string, message: string) => {
activeRunIsWorkflow = false;
activeRunIsWorkflow.value = false;
// AI loading 占位气泡
const aiMsgId = 'msg-' + Date.now() + '-ai';
addMessage({ id: aiMsgId, content: '', time: formatTime(new Date()), isUser: false, loading: true });
@@ -1253,18 +1340,20 @@ const handleSelectHistory = async (id: string) => {
// 表单卡片直接用结果记录的 requestParams(完整 flowContent,含 __start__ outputConfig 值快照)构造,
// 不再依赖 execution/getchat 结果按原逻辑转消息流
const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoResult[]) => {
// 最近一次 workflow 结果记录(results 时间倒序,第一条即最新):它的 requestParams 即回显表单值来源
// 最近一次 workflow 结果记录(results 时间倒序,第一条即最新):用于同步 InputBar 锁定标签展示
const latestWf = Array.isArray(results) ? results.find((r) => r.type === 'workflow') : undefined;
// 同步回显 InputBar 的工作流选择(锁定标签展示):按 flowId 匹配commonWorkflows 的 id 即工作流 id
let flowName = '';
// 按 flowId 查工作流名commonWorkflows 的 id 即工作流 id,每条记录用各自名称
const flowNameOf = (flowId?: string): string => {
if (!flowId) return '';
const ib = inputBarRef.value as any;
const match = ib?.commonWorkflows?.find((w: any) => String(w.id) === String(flowId));
return match ? String(match.name || '') : '';
};
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 || '');
}
if (match) ib.selectedWorkflowId = match.id;
}
}
const msgs: ChatMessage[] = [];
@@ -1277,8 +1366,9 @@ const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoRe
} 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)) {
// 每次执行都渲染带值表单卡片(requestParams.nodes 即该次提交的表单值快照);
// form 补 id=flowId,供失败卡片「重新编辑并执行」时作为启动 flowId
if (Array.isArray(r.requestParams?.nodes)) {
msgs.push({
id: 'form-' + r.id,
content: '',
@@ -1286,7 +1376,8 @@ const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoRe
isUser: false,
type: 'form',
form: {
flowName,
id: r.flowId,
flowName: flowNameOf(r.flowId) || flowNameOf(latestWf?.flowId),
nodeInputParams: r.requestParams.nodes,
flowContent: r.requestParams,
},
@@ -1294,15 +1385,25 @@ const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoRe
formError: failed ? r.errorMsg || '执行失败' : '',
});
}
// 结果消息
msgs.push({
// 结果消息:成功记录异步拉取产出(getExecutionDetail → outputParams/fileUrls)附加到 outputs
const resultMsg: ChatMessage = {
id: failed ? 'wfail-' + r.id : 'wok-' + r.id,
content: failed ? '❌ ' + (r.errorMsg || '执行失败,请重试或联系管理员') : '✅ 执行完成,可前往工作空间查看产出',
time: r.createdAt || '',
isUser: false,
recordId: String(r.id),
recordType: r.type,
});
outputs: [],
};
msgs.push(resultMsg);
if (!failed) {
getExecutionDetail(String(r.id))
.then((res) => {
const outputs = extractWorkflowOutputs(res?.data, String(r.id));
if (outputs.length) resultMsg.outputs = outputs;
})
.catch(() => {});
}
}
});
sessionMessages.value.set(sid, msgs);
+51
View File
@@ -143,3 +143,54 @@ export function checkTemplateMissing(flowContent: any): { nodeId: string; nodeNa
}
return missing;
}
// ===== 执行产出提取 =====
// 会话内展示工作流执行产出(文件卡片):来自 getExecutionDetail 返回的 WorkflowItem
// 合并 fileUrls / outputParams 各 value / resultUrl,去重后归一为 { url, name, type, backendId }。
// type 用于预览模式判定(image/video/audio/text/file),backendId 为执行记录 id(删除产出用)。
export interface WorkflowOutput {
url: string;
name: string;
type: string;
backendId: string;
}
// 按文件扩展名推断预览类型
function guessFileType(url: string): string {
const clean = String(url).split('?')[0] || '';
const ext = (clean.split('.').pop() || '').toLowerCase();
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext)) return 'image';
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'video';
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
if (['txt', 'md', 'json', 'log', 'csv'].includes(ext)) return 'text';
return 'file';
}
export function extractWorkflowOutputs(data: any, backendId?: string): WorkflowOutput[] {
if (!data) return [];
const seen = new Set<string>();
const outputs: WorkflowOutput[] = [];
const push = (rawUrl: any) => {
if (typeof rawUrl !== 'string') return;
const url = rawUrl.trim();
if (!url || seen.has(url)) return;
seen.add(url);
const name = String(url).split('?')[0].split('/').pop() || '产出文件';
outputs.push({
url,
name,
type: guessFileType(url),
backendId: backendId || data.id || '',
});
};
if (Array.isArray(data.fileUrls)) data.fileUrls.forEach(push);
if (Array.isArray(data.outputParams)) {
data.outputParams.forEach((o: any) => {
if (!o || typeof o !== 'object') return;
Object.values(o).forEach((v) => push(v));
});
}
if (data.resultUrl) push(data.resultUrl);
return outputs;
}
+25 -2
View File
@@ -6,7 +6,9 @@
// {"type":"reasoning_chunk","message":"思考中","data":{"delta":"你"}} 思考内容增量(逐 chunk)
// {"type":"answer_chunk","message":"回答中","data":{"delta":"你"}} 回答内容增量(逐 chunk)
// {"type":"answer","message":"作答完成","data":{"answer":"..."}} 最终回答
// {"type":"error","message":"..."} 执行失败
// {"type":"node_start","message":"开始执行(2/4): xxx","data":{"nodeCount":4,"nodeId":"..","nodeIndex":2,"nodeName":".."}} 工作流节点开始(进度推进)
// {"type":"node_complete","message":"执行完成(2/4): xxx","data":同上} 工作流节点完成(进度推进)
// {"type":"error","message":"...","error":"节点失败详情"} 执行失败(error 字段含详细原因)
// 完成/失败判定均以 type 为准,不再用正则猜测文本。
export interface WsStreamMessage {
@@ -14,6 +16,8 @@ export interface WsStreamMessage {
message?: string;
data?: any;
payload?: any;
// 失败详情:error 事件携带的详细原因(含节点名与具体失败信息),比 message 更具体
error?: string;
}
/** 后端 ReAct WebSocket 事件类型常量 */
@@ -24,6 +28,8 @@ export const WsEventType = {
Answer: 'answer', // 最终回答
AnswerChunk: 'answer_chunk', // 回答内容增量(逐 chunk
ReasoningChunk: 'reasoning_chunk', // 思考内容增量(逐 chunk
NodeStart: 'node_start', // 工作流节点开始(进度推进)
NodeComplete: 'node_complete', // 工作流节点完成(进度推进)
Error: 'error', // 出错
} as const;
@@ -62,6 +68,21 @@ export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: numb
return { step, maxStep: typeof maxStep === 'number' ? maxStep : 0 };
}
/** 工作流节点进度:node_start / node_complete → { current, total, nodeName },非节点事件返回 null */
export function getNodeProgress(msg: WsStreamMessage): { current: number; total: number; nodeName: string } | null {
const t = msg?.type;
if (t !== WsEventType.NodeStart && t !== WsEventType.NodeComplete) return null;
const d = msg?.data;
const nodeIndex = d?.nodeIndex;
const nodeCount = d?.nodeCount;
if (typeof nodeIndex !== 'number' || typeof nodeCount !== 'number') return null;
return {
current: nodeIndex,
total: nodeCount,
nodeName: typeof d?.nodeName === 'string' ? d.nodeName : '',
};
}
/** tool_call → 工具名称(tool / toolName / name 依次尝试,缺失返回空串) */
export function getToolCallName(msg: WsStreamMessage): string {
const d = msg?.data;
@@ -87,8 +108,10 @@ export function getToolResultText(msg: WsStreamMessage): string {
return '';
}
/** error → 错误文案(message 优先,其次 data.message / data.error / data.msg */
/** error → 错误文案(error 字段为详细原因优先;其次 message;再 data.message / data.error / data.msg */
export function getErrorText(msg: WsStreamMessage): string {
// 详细失败原因(含节点名/具体错误),优先展示
if (typeof msg?.error === 'string' && msg.error) return msg.error;
if (typeof msg?.message === 'string' && msg.message) return msg.message;
const d = msg?.data;
if (typeof d === 'string') return d;