首页工作流表单卡片化闭环:执行进度、输入禁用与取消入口
- socket 节点事件推进表单卡片执行进度,失败详情展示详细原因 - 工作流执行时禁用输入框,取消入口移到卡片 footer,卡片定格可重试 - 工作流 ModelRequestParams 按详情原样透传后端 - 会话内产出文件卡片展示(预览/下载/删除),移除顶部结果卡片区 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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.question;workflow 不再平铺 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>
|
||||
@@ -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
@@ -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/get;chat 结果按原逻辑转消息流
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user