首页工作流与历史会话:修复执行后表单清空/默认展开,工作流最多展示9个+更多入口,历史会话分页加载更多

- 修复:执行时把用户填的表单值写回消息对象,会话认领重建后能恢复,不再清空;执行完成/历史回显卡片默认收起,可手动展开改参
- 功能:首页工作流最多展示 9 个(用户优先、模板补足),超出显示「更多」入口跳转工作流管理页
- 功能:历史会话分页「加载更多」,逐页追加去重
- 重构:工作流执行信号统一走提交(移除 retry/re-edit/cancel),失败卡片重跑/重编辑并入提交路径
- 其他:删除消息时终止进行中的轮;Markdown 渲染缓存设上限

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:17:12 +08:00
co-authored by Claude
parent 0232dc6e14
commit b140bff5f1
6 changed files with 248 additions and 118 deletions
+6 -9
View File
@@ -23,9 +23,6 @@
:form-error="msg.formError"
:progress="msg.formProgress"
@submit="emit('form-submit', msg, $event)"
@retry="emit('form-retry', msg, $event)"
@re-edit="emit('form-re-edit', msg)"
@cancel="emit('form-cancel', msg)"
/>
<template v-else>
<div class="bubble">
@@ -65,7 +62,7 @@
@delete="emit('output-delete', msg, $event)"
/>
<!-- AI 消息操作栏hover 显示 -->
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
<div v-if="!msg.isUser && msg.content" class="msg-actions">
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
<el-icon><DocumentCopy /></el-icon>
</button>
@@ -122,9 +119,6 @@ interface ChatMessage {
interface Emits {
(e: 'delete', msg: ChatMessage): void;
(e: 'form-submit', msg: ChatMessage, payload: any): void;
(e: 'form-retry', 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;
@@ -137,9 +131,9 @@ interface Props {
loadingMore?: boolean;
// 产出相对路径 → 完整 URL 解析函数(由首页传入 buildAssetUrl),内嵌预览媒体需完整地址
resolveUrl?: (url: string) => string;
// InputBar 发送按钮触发执行:父组件定位目标卡片后,经此信号调用卡片内部 submit/retry
// InputBar 发送按钮触发执行:父组件定位目标卡片后,经此信号调用卡片内部提交
// (卡片参数在子组件内部,父组件无法直接取值,故由卡片暴露方法、这里中转调用)
executeRequest?: { msgId: string; action: 'submit' | 'retry'; nonce: number } | null;
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
}
const props = withDefaults(defineProps<Props>(), {
@@ -169,8 +163,11 @@ const toggleThinking = (msg: ChatMessage) => {
};
// Markdown 渲染缓存(content 在作答完成后固定,避免重复解析)
// 设上限:防止长会话渲染结果无限累积(超限清空重建,量级可控)
const mdCache = new Map<string, string>();
const MD_CACHE_MAX = 300;
const renderAnswer = (content: string): string => {
if (mdCache.size >= MD_CACHE_MAX) mdCache.clear();
if (!mdCache.has(content)) mdCache.set(content, renderMarkdown(content));
return mdCache.get(content)!;
};
+37 -18
View File
@@ -29,15 +29,6 @@
<!-- 底部工具栏 -->
<div class="input-toolbar">
<div class="toolbar-left">
<button class="tool-icon-btn" title="上传文件" @click="handleAttachment">
<el-icon><Paperclip /></el-icon>
</button>
<!-- 技能暂已禁用 -->
<button class="tool-icon-btn" title="技能暂不可用" disabled>
<el-icon><MagicStick /></el-icon>
<span class="tool-label">技能</span>
</button>
<!-- 切换会话模型入口 -->
<button class="tool-icon-btn" :class="{ active: !!currentModelName }" title="切换会话模型" @click="emit('select-model')">
@@ -75,6 +66,16 @@
{{ wf.name }}
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
</button>
<!-- 工作流数量超出展示上限时提供更多入口跳转工作流管理页查看/选择全部 -->
<button
v-if="commonWorkflows.length > MAX_WORKFLOWS"
class="shortcut-pill shortcut-more"
title="查看全部工作流"
@click="handleOpenWorkflowManage"
>
<el-icon><More /></el-icon>
更多
</button>
</div>
</Transition>
</div>
@@ -82,8 +83,8 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Top, MagicStick, Promotion, Close, Paperclip, VideoPause, Cpu } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import { Top, Promotion, Close, VideoPause, Cpu, More } from '@element-plus/icons-vue';
import { getWorkflowList } from '/@/api/settings/creation';
interface Props {
@@ -124,6 +125,14 @@ const props = withDefaults(defineProps<Props>(), {
isWorkflowSession: false,
});
const emit = defineEmits<Emits>();
const router = useRouter();
// 首页展示工作流上限:用户工作流优先,不足用系统模板补足,最多 9 个
const MAX_WORKFLOWS = 9;
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
const handleOpenWorkflowManage = () => {
router.push('/settings/workflow');
};
const message = ref('');
const isFocused = ref(false);
const selectedWorkflowId = ref<string | null>(null);
@@ -131,9 +140,9 @@ const commonWorkflows = ref<Workflow[]>([]);
// 取消工作流前的选择:父组件确认「继续编辑」时回滚,避免 InputBar 先置空、父侧保留草稿造成状态脱钩
const previousSelectedWorkflowId = ref<string | null>(null);
// 工作流胶囊始终展示全部工作流:历史工作流会话回显预选上次执行的工作流
// 但仍允许自由切换/取消(不锁定为单个工作流)
const visibleWorkflows = computed(() => commonWorkflows.value);
// 工作流胶囊最多展示 MAX_WORKFLOWS 个:commonWorkflows 用户工作流在前、模板在后
// 截断前 MAX_WORKFLOWS 个即「用户优先、不足模板补足」;完整列表仍保留供父组件匹配
const visibleWorkflows = computed(() => commonWorkflows.value.slice(0, MAX_WORKFLOWS));
// 输入框整体锁定:正处于生成/工作流执行中(执行中禁止切换工作流)
const lockAll = computed(() => props.isWorkflowRunning || props.generating);
@@ -163,6 +172,7 @@ const fetchWorkflows = async () => {
const res = await getWorkflowList();
const userList = res.data?.listFlowUserRes?.list || [];
const tplList = res.data?.listFlowTemplateRes?.list || [];
// commonWorkflows 保留完整列表:父组件按 id 匹配工作流名称/预选历史会话,不能被展示截断影响
const workflows = [
...userList.map((w) => ({ ...w, isTemplate: false })),
...tplList.map((w) => ({ ...w, isTemplate: true })),
@@ -198,10 +208,6 @@ const handleSend = () => {
// 不清除工作流选择,保持表单可见
};
const handleAttachment = () => {
ElMessage.info('附件上传功能开发中...');
};
const toggleWorkflow = (id: string) => {
if (lockAll.value) return;
const item = commonWorkflows.value.find((w) => w.id === id);
@@ -511,6 +517,19 @@ const onCapsuleAfterLeave = (el: Element) => {
}
}
/* 「更多」入口:与普通胶囊区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
.shortcut-more {
border-style: dashed;
color: #94a3b8;
&:hover {
border-style: dashed;
border-color: #93c5fd;
color: #2563eb;
background: rgba(239, 246, 255, 0.9);
}
}
.pill-tag {
font-size: 10px;
line-height: 1;
+52 -9
View File
@@ -12,9 +12,6 @@
class="chat-list-scroll"
@delete="emit('delete', $event)"
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
@form-retry="(msg: any, payload: any) => emit('form-retry', 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)"
@@ -40,6 +37,17 @@
<span class="wf-card-name">{{ wf.name }}</span>
<span v-if="wf.isTemplate" class="wf-card-tag">模板</span>
</button>
<!-- 工作流数量超出展示上限时提供「更多」入口:跳转工作流管理页 -->
<button
v-if="hasMorePlaceWorkflows"
type="button"
class="workflow-card wf-card-more"
title="查看全部工作流"
@click="goWorkflowManage"
>
<el-icon class="wf-card-icon"><More /></el-icon>
<span class="wf-card-name">更多工作流</span>
</button>
</div>
<div v-else class="placeholder-empty">暂无工作流,可在工作流管理中创建</div>
</div>
@@ -50,8 +58,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getWorkflowList } from '/@/api/settings/creation';
import { Promotion } from '@element-plus/icons-vue';
import { Promotion, More } from '@element-plus/icons-vue';
import ChatList from './ChatList.vue';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
@@ -64,16 +73,13 @@ interface Props {
hasMore?: boolean;
loadingMore?: boolean;
// InputBar 发送按钮触发执行信号(透传给 ChatList,定位工作流卡片执行)
executeRequest?: { msgId: string; action: 'submit' | 'retry'; nonce: number } | null;
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
}
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-retry', 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;
@@ -89,28 +95,39 @@ const props = withDefaults(defineProps<Props>(), {
executeRequest: null,
});
const emit = defineEmits<Emits>();
const router = useRouter();
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
// 占位页工作流卡片(DeepSeek 式引导:居中标题 + 工作流卡片)
// 展示上限与 InputBar 胶囊保持一致:用户工作流优先,不足用模板补足,最多 9 个
const MAX_PLACE_WORKFLOWS = 9;
// 工作流总数超过展示上限时显示「更多」入口(跳转工作流管理页)
const hasMorePlaceWorkflows = ref(false);
const placeWorkflows = ref<any[]>([]);
const loadPlaceWorkflows = async () => {
try {
const res: any = await getWorkflowList();
const userList = res.data?.listFlowUserRes?.list || [];
const tplList = res.data?.listFlowTemplateRes?.list || [];
hasMorePlaceWorkflows.value = userList.length + tplList.length > MAX_PLACE_WORKFLOWS;
placeWorkflows.value = [
...userList.map((w: any) => ({ id: String(w.id), name: w.flowName || '未命名', isTemplate: false })),
...tplList.map((w: any) => ({ id: String(w.id), name: w.flowTemplateName || '未命名', isTemplate: true })),
];
].slice(0, MAX_PLACE_WORKFLOWS);
} catch {
placeWorkflows.value = [];
hasMorePlaceWorkflows.value = false;
}
};
const handlePlaceSelect = (wf: any) => {
emit('workflow-select', wf.id, wf.isTemplate);
};
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
const goWorkflowManage = () => {
router.push('/settings/workflow');
};
onMounted(() => {
loadPlaceWorkflows();
});
@@ -228,6 +245,32 @@ onMounted(() => {
border: 1px solid #fde68a;
white-space: nowrap;
}
/* 「更多」入口:与普通工作流卡片区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
.wf-card-more {
border-style: dashed;
justify-content: center;
.wf-card-icon {
background: #f8fafc;
color: #94a3b8;
}
.wf-card-name {
color: #94a3b8;
font-weight: 500;
}
&:hover {
border-style: dashed;
border-color: #93c5fd;
.wf-card-icon {
background: #eaf1ff;
color: #2563eb;
}
.wf-card-name {
color: #2563eb;
}
}
}
.placeholder-empty {
font-size: 13px;
color: #cbd5e1;
+41
View File
@@ -45,6 +45,12 @@
<el-icon><Delete /></el-icon>
</button>
</div>
<!-- 历史会话分页加载更多还有更多会话时展示点击加载下一页 -->
<div v-if="historyHasMore" class="history-load-more">
<button class="load-more-btn" :disabled="historyLoadingMore" @click="emit('load-more-history')">
{{ historyLoadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
<div v-else-if="historyFailed" class="panel-error">
@@ -108,6 +114,9 @@ interface Props {
historyList: HistoryItem[];
// 会话记录 / 工作空间加载失败标记:失败时显示「加载失败 + 重试」错误态
historyFailed?: boolean;
// 历史会话分页:是否还有更多可加载、加载更多请求中
historyHasMore?: boolean;
historyLoadingMore?: boolean;
workspaceFailed?: boolean;
// 工作空间是否已成功加载:懒加载成功后才不再触发;失败保持 false → 切回 tab 自动重试
workspaceLoaded?: boolean;
@@ -125,11 +134,14 @@ interface Emits {
(e: 'delete-node', data: TreeNode): void;
(e: 'load-workspace'): void;
(e: 'retry-history'): void;
(e: 'load-more-history'): void;
(e: 'retry-workspace'): void;
}
const props = withDefaults(defineProps<Props>(), {
historyFailed: false,
historyHasMore: false,
historyLoadingMore: false,
workspaceFailed: false,
workspaceLoaded: false,
});
@@ -511,6 +523,35 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
animation: pulse 1.5s ease-in-out infinite;
}
/* 历史会话分页「加载更多」按钮 */
.history-load-more {
padding: 10px 0 4px;
text-align: center;
}
.load-more-btn {
border: 1px solid #e5e8ee;
background: #fff;
color: #64748b;
font-size: 12px;
padding: 5px 20px;
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
outline: none;
&:hover:not(:disabled) {
border-color: #93c5fd;
color: #2563eb;
background: #eff6ff;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
+7 -28
View File
@@ -202,20 +202,14 @@ interface Props {
formError?: string;
// 执行节点进度(node_start/node_complete 事件驱动的步骤列表,渲染执行过程区)
progress?: { nodes: WorkflowNodeStep[] };
// InputBar 发送按钮触发执行信号:仅当本卡片为目标时非 null(action 决定 submit/retry
executeSignal?: { action: 'submit' | 'retry'; nonce: number } | null;
// InputBar 发送按钮触发执行信号:仅当本卡片为目标时非 null(统一走提交,含必填校验
executeSignal?: { action: 'submit'; nonce: number } | null;
// 已执行卡片(done/failed)参数可编辑(会话中最后一张执行过的工作流):改参后经发送按钮重新执行
editable?: boolean;
}
interface Emits {
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
// 失败卡片「重新编辑并执行」:由父组件把 formStatus 切回 editing,保留已填值
(e: 're-edit'): void;
// 失败卡片「重新执行」:复用当前已填值直接重跑(不进入编辑态)
(e: 'retry', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
// 执行中取消:工作流取消入口(InputBar 停止按钮对工作流禁用)
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
@@ -255,8 +249,9 @@ watch(
if (st === 'editing') {
collapsed.value = false; // 回显/重新编辑:默认展开
} else if (prev === undefined) {
// 初始即非编辑态(执行中/完成/失败):可编辑的最后一张已执行卡片展开供改参,其余收起
collapsed.value = props.editable ? false : true;
// 初始即非编辑态(执行中/完成/失败):一律收起(执行后默认收起,不因可编辑展开),
// 用户可手动展开查看已填值/改参
collapsed.value = true;
} else if (st === 'running') {
collapsed.value = true; // 提交瞬间自动收起
}
@@ -551,25 +546,13 @@ const handleSubmit = () => {
});
};
// 直接重跑:复用当前已填值(必填此前已校验通过),不进入编辑态原样重新提交。
// 由 InputBar 发送按钮触发(未重新选择工作流时默认重跑最后一个),成功/失败定格卡片均可重跑
const handleRetry = () => {
if (!props.readonly || props.submitting) return;
emit('retry', {
formValues: JSON.parse(JSON.stringify(formValues)),
formFileNames: JSON.parse(JSON.stringify(formFileNames)),
templates: JSON.parse(JSON.stringify(templates.value || [])),
});
};
// InputBar 发送按钮触发执行:父组件把执行信号下发给目标卡片(本卡片为目标时 executeSignal 非 null),
// 编辑态调用提交(含必填校验)、定格态调用重跑(复用已填值);卡片参数在组件内部,父组件经信号间接触发
// 统一调用提交(含必填校验);卡片参数在组件内部,父组件经信号间接触发
watch(
() => props.executeSignal,
(sig) => {
if (!sig) return;
if (sig.action === 'submit') handleSubmit();
else handleRetry();
handleSubmit();
}
);
</script>
@@ -814,10 +797,6 @@ watch(
text-overflow: ellipsis;
white-space: nowrap;
}
.exec-cancel-btn {
margin-left: auto;
flex-shrink: 0;
}
}
@keyframes exec-spin {
to { transform: rotate(360deg); }
+105 -54
View File
@@ -4,6 +4,8 @@
:active-menu="activeMenu"
:active-history-id="activeHistoryId"
:history-list="historyList"
:history-has-more="historyHasMore"
:history-loading-more="historyLoadingMore"
:history-failed="sessionHistoryFailed"
:tree-nodes="treeNodes"
:tree-loading="treeLoading"
@@ -18,6 +20,7 @@
@delete-node="handleDeleteNode"
@load-workspace="loadWorkspaceTree"
@retry-history="loadSessionHistory"
@load-more-history="handleLoadMoreHistory"
@retry-workspace="loadWorkspaceTree"
/>
<div class="main-wrapper">
@@ -31,9 +34,6 @@
:execute-request="executeRequest"
@delete="handleDeleteMessage"
@form-submit="handleFormSubmit"
@form-retry="handleFormRetry"
@form-re-edit="handleFormReEdit"
@form-cancel="handleFormCancel"
@output-preview="handleOutputPreview"
@output-download="handleOutputDownload"
@output-delete="handleOutputDelete"
@@ -166,6 +166,13 @@ const previewUrl = ref('');
const previewMode = ref<'iframe' | 'image' | 'video' | 'audio'>('iframe');
const historyList = ref<HistoryItem[]>([]);
// 历史会话分页:后端按 pageNum/pageSize 返回,前端逐页追加;记录已加载页、是否还有更多、加载更多请求中
const HISTORY_PAGE_SIZE = 10;
const historyPage = ref(1);
const historyHasMore = ref(false);
const historyLoadingMore = ref(false);
// 新建会话序号:基于历史标题「新会话 N」最大序号递增,删除会话后序号不重复
let newChatSeq = 0;
const sessionMessages = ref<Map<string, ChatMessage[]>>(new Map());
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
@@ -333,48 +340,89 @@ const loadWorkspaceTree = async () => {
}
};
// 会话记录原始项 → 列表展示项:今天只显示时分,其他日期显示日期,标题取会话名
const formatHistoryItem = (s: any, todayStr: string): HistoryItem => {
const rawTime = s.createdAt || '';
let displayTime = rawTime;
if (rawTime) {
const datePart = rawTime.substring(0, 10);
displayTime = datePart === todayStr ? rawTime.substring(11, 16) : datePart;
}
return {
id: String(s.sessionId),
sessionId: String(s.sessionId),
title: s.sessionName || '未命名会话',
time: displayTime,
status: mapSessionStatus(s.status),
};
};
// 是否还有更多历史会话:已加载条数小于后端总数即判定还有下一页
const syncHistoryHasMore = (total: number) => {
historyHasMore.value = historyList.value.length < total;
};
// 会话记录列表加载(独立容错:会话列表失败不影响工作空间结果树)
const loadSessionHistory = async () => {
try {
const sessionRes = await getSessionListV2({ pageNum: 1, pageSize: 10 });
const sessionRes = await getSessionListV2({ pageNum: 1, pageSize: HISTORY_PAGE_SIZE });
const d = new Date();
const todayStr = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const sessions = getSessionData(sessionRes)
// 后端个别会话 sessionId 可能为空串,空 id 无法点击查询详情,过滤掉
.filter((s) => s.sessionId)
.map((s) => {
const rawTime = s.createdAt || '';
let displayTime = rawTime;
if (rawTime) {
const datePart = rawTime.substring(0, 10);
if (datePart === todayStr) {
displayTime = rawTime.substring(11, 16);
} else {
displayTime = datePart;
}
}
return {
id: String(s.sessionId),
sessionId: String(s.sessionId),
title: s.sessionName || '未命名会话',
time: displayTime,
status: mapSessionStatus(s.status),
};
});
.map((s) => formatHistoryItem(s, todayStr));
historyList.value = sessions;
sessionHistoryFailed.value = false;
// 重置分页基准:回到第 1 页
historyPage.value = 1;
syncHistoryHasMore(sessionRes?.data?.total ?? historyList.value.length);
// 为每个后端会话初始化消息存储
sessions.forEach((s) => {
if (!sessionMessages.value.has(s.id)) {
sessionMessages.value.set(s.id, []);
}
});
// 同步新建序号基准:删除会话后新建序号不重复
newChatSeq = historyList.value.reduce((max, h) => {
const m = /^新会话 (\d+)$/.exec(h.title);
return m ? Math.max(max, Number(m[1])) : max;
}, 0);
} catch {
// 失败保留已有会话数据(首次为空则显示错误态),标记失败供 UI 提供重试入口
sessionHistoryFailed.value = true;
}
};
// 历史会话「加载更多」:按页追加下一页;新建/删除会改变后端列表顺序,追加前按 id 去重防重复
const handleLoadMoreHistory = async () => {
if (historyLoadingMore.value || !historyHasMore.value) return;
historyLoadingMore.value = true;
try {
const nextPage = historyPage.value + 1;
const sessionRes = await getSessionListV2({ pageNum: nextPage, pageSize: HISTORY_PAGE_SIZE });
const d = new Date();
const todayStr = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const existIds = new Set(historyList.value.map((h) => h.id));
const more = getSessionData(sessionRes)
.filter((s) => s.sessionId && !existIds.has(String(s.sessionId)))
.map((s) => formatHistoryItem(s, todayStr));
historyList.value.push(...more);
historyPage.value = nextPage;
syncHistoryHasMore(sessionRes?.data?.total ?? historyList.value.length);
// 新加载会话同样初始化消息存储
more.forEach((s) => {
if (!sessionMessages.value.has(s.id)) {
sessionMessages.value.set(s.id, []);
}
});
} catch {
// 加载更多失败:保留已加载列表,不置失败态(可再次点击重试)
} finally {
historyLoadingMore.value = false;
}
};
// ===== 会话模型:展示当前会话模型名称 =====
const loadCurrentChatModel = async () => {
try {
@@ -631,7 +679,7 @@ const ensureActiveSession = (): { sid: string } => {
historyList.value.unshift({
id: newId,
sessionId: newId,
title: '新会话 ' + (historyList.value.length + 1),
title: '新会话 ' + ++newChatSeq,
time: '刚刚',
});
activeHistoryId.value = newId;
@@ -886,6 +934,8 @@ const handleDeleteMessage = async (msg: ChatMessage) => {
} catch {
return;
}
// 若该会话有进行中的轮(删除的是正在生成的消息):先终止生成,避免后台继续推流、发送按钮僵在停止态
if (roundHandlers.has(sid)) teardownRound(sid);
try {
await deleteSessionRecord(ids);
ElMessage.success('已删除');
@@ -921,7 +971,7 @@ const handleStopGenerate = () => {
// ===== InputBar 发送按钮(工作流场景)执行 =====
// 目标工作流卡片执行信号:下发给目标卡片(ChatList 经 executeRequest → executeSignal),卡片内部提交/重跑。
// 卡片参数在组件内部,父组件经信号间接触发、不直接取值。
const executeRequest = ref<{ msgId: string; action: 'submit' | 'retry'; nonce: number } | null>(null);
const executeRequest = ref<{ msgId: string; action: 'submit'; nonce: number } | null>(null);
// 1) 会话存在编辑态卡片(用户已选工作流在填参数)→ 提交该卡片;
// 2) 否则未重新选择工作流 → 默认重跑最后一张已执行(done/failed)的卡片
const handleExecuteWorkflow = () => {
@@ -1136,6 +1186,26 @@ const runWorkflow = async (
};
// ===== 表单卡片执行入口:提交 / 失败重试共用(卡片内按钮触发) =====
// 提交时把用户填的表单值写回消息对象开始节点 outputConfig(只写 value 不删字段,与执行用副本分离)。
// 会话认领(virtual→UUID 触发 MainContent 重建)/ 历史回显等重挂载场景,WorkflowFormCard 从 msg.form 重新
// 初始化时能恢复已填值,避免执行后表单被清空。
const snapshotFormValuesToMsg = (msg: ChatMessage, formValues: Record<string, any>) => {
const nodes = msg.form?.nodeInputParams;
if (!Array.isArray(nodes)) return;
const startNode = nodes.find((n: any) => String(n?.nodeCode || '').toLowerCase() === '__start__');
if (!startNode || !Array.isArray(startNode.outputConfig)) return;
startNode.outputConfig.forEach((f: any) => {
if (!f || typeof f !== 'object') return;
// 引用上游节点输出的字段只读展示,值由上游节点运行时提供,不写回
if (f.valueSource && typeof f.valueSource === 'object') return;
const field = f.field !== undefined ? f.field : f.path;
if (field === undefined || field === '') return;
const key = `${startNode.id || startNode.nodeCode}|${field}`;
if (formValues[key] !== undefined) f.value = formValues[key];
});
};
const startWorkflowFromCard = async (
msg: ChatMessage,
payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[] }
@@ -1152,6 +1222,11 @@ const startWorkflowFromCard = async (
const session = historyList.value.find((h) => h.id === sid);
if (!session || !msg.form) return;
// 值快照写回消息对象:提交时把用户填的表单值写回 msg.form 开始节点 outputConfig
// 会话认领(virtual→UUID 触发 MainContent 重建)/ 历史回显重挂载时能从消息对象恢复已填值,
// 避免执行后表单被清空
snapshotFormValuesToMsg(msg, payload.formValues);
// 标记本轮发送中:工作流执行期间 InputBar 显示停止按钮(isGenerating 依赖 sendingSessions
sendingSessions[sid] = true;
// 卡片定格执行中,禁用重复提交;表单卡片 running 态即提交意图,不追加用户消息
@@ -1168,32 +1243,6 @@ const startWorkflowFromCard = async (
// 表单提交:用户填写后点击「提交执行」
const handleFormSubmit = (msg: ChatMessage, payload: any) => startWorkflowFromCard(msg, payload);
// 失败后「重新执行」:复用当前已填值直接重跑(卡片 handleRetry 收集当前值后触发,不进入编辑态)
const handleFormRetry = (msg: ChatMessage, payload: any) => startWorkflowFromCard(msg, payload);
// 失败卡片「重新编辑并执行」:切回 editing 保留已填值(组件内 formValues 随消息保留,
// detail 未变不触发重置),改完重新提交
const handleFormReEdit = (msg: ChatMessage) => {
msg.formStatus = 'editing';
msg.formError = undefined;
// 重新编辑:清除上次执行的节点进度,待再次提交后重新累积
msg.formProgress = undefined;
};
// 工作流执行中取消(卡片 footer「取消执行」):复用 teardownRound —— 发 cancel 帧 + 本轮 abort
// abort 把卡片定格为「执行已取消」(保留已填值,可重新编辑并执行)
const handleFormCancel = (msg: ChatMessage) => {
// 定位卡片所在会话:优先活跃会话,其次按消息列表反查(卡片必在当前活跃会话渲染)
let sid = activeHistoryId.value || '';
for (const [k, list] of sessionMessages.value) {
if (list.includes(msg)) {
sid = k;
break;
}
}
if (sid) teardownRound(sid);
};
// ===== 会话内产出文件卡片操作:预览 / 下载 / 删除 =====
const handleOutputPreview = (msg: ChatMessage, output: WorkflowOutput) => {
if (!output.url) return;
@@ -1336,7 +1385,7 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
// 成功兜底:后端未推任何回复内容时,给出明确提示(避免空白气泡)
const ai = list[aiIdx];
if (!ai.content) {
ai.content = '⚠️ 未收到回复内容,请重试';
ai.content = '⚠️ 本次未收到回复内容';
}
}
// 更新会话状态
@@ -1490,6 +1539,8 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
const handleSelectHistory = async (id: string) => {
// 防重复点击同一条,避免误杀正在生成的轮
if (id === activeHistoryId.value) return;
// 切换会话:刷新会话模型展示(会话模型可能与会话关联,避免显示陈旧)
void loadCurrentChatModel();
// 多会话并发:切会话不再终止旧轮/关闭旧连接,后台执行继续;
// 仅当旧会话当前无执行任务时释放其空闲连接(切回时惰性重连)
const prevId = activeHistoryId.value;
@@ -1577,7 +1628,7 @@ const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoRe
// 结果消息:成功记录直接带产出(session/get 记录自带的 resultFileUrl),不再调 execution/get
const resultMsg: ChatMessage = {
id: failed ? 'wfail-' + r.id : 'wok-' + r.id,
content: failed ? '❌ ' + (r.errorMsg || '执行失败,请重试或联系管理员') : '✅ 执行完成,可前往工作空间查看产出',
content: failed ? '❌ ' + (r.errorMsg || '执行失败,请重试或联系管理员') : '✅ 执行完成',
time: r.createdAt || '',
isUser: false,
recordId: String(r.id),
@@ -1689,7 +1740,7 @@ const createNewSession = () => {
historyList.value.unshift({
id: sessionId,
sessionId,
title: '新会话 ' + (historyList.value.length + 1),
title: '新会话 ' + ++newChatSeq,
time: '刚刚',
});
activeHistoryId.value = sessionId;