首页工作流会话重构与产出内嵌:后端 UUID 认领 + 产出对话内直接预览

会话机制:
- 建连不传本地会话号,由后端生成 UUID 并经首帧 ack 认领(claimSessionId 就地迁移全部状态)
- 删除执行完成后轮询会话列表替换本地条目的双路径逻辑,统一认领后刷新
- 清理 virtual_ 特判,收敛为 hasBackendSessionId 单一语义判断

产出展示:
- flow_complete 事件自带 resultFileUrls 直接构建产出卡片(buildOutputsFromUrls)
- 完成文案改为「 执行完成」,产出以消息形式展示在对话里
- 产出媒体(图片/视频/音频)常驻内嵌预览,图片点击放大,text/file 保持文件行
- ChatList 自动续载增加次数上限,防止 hasMore 恒真时无限加载循环
This commit is contained in:
2026-08-17 17:59:24 +08:00
parent bb15c053e6
commit 13a2aeee9f
6 changed files with 349 additions and 110 deletions
+26 -3
View File
@@ -60,6 +60,7 @@
<WorkflowOutputCard
v-if="msg.outputs && msg.outputs.length"
:outputs="msg.outputs"
:resolve-url="props.resolveUrl"
@preview="emit('output-preview', msg, $event)"
@download="emit('output-download', msg, $event)"
@delete="emit('output-delete', msg, $event)"
@@ -139,11 +140,14 @@ interface Props {
messages: ChatMessage[];
hasMore?: boolean;
loadingMore?: boolean;
// 产出相对路径 → 完整 URL 解析函数(由首页传入 buildAssetUrl),内嵌预览媒体需完整地址
resolveUrl?: (url: string) => string;
}
const props = withDefaults(defineProps<Props>(), {
hasMore: false,
loadingMore: false,
resolveUrl: undefined,
});
const emit = defineEmits<Emits>();
const chatListRef = ref<HTMLElement | null>(null);
@@ -201,6 +205,11 @@ const thinkingTitle = (msg: ChatMessage): string => {
// autoLoading 标记本次 load-more 是否由"自动续载"触发:自动续载不保持位置(落在最新),用户上滑保持视口
let autoLoading = false;
// 自动续载累计次数上限:连续自动加载超过上限即停,防止异常数据(如 hasMore 恒 true 且视口不满)
// 导致 load-more 无限循环占满主线程。用户主动上滑加载不受此上限约束(见 handleScroll)。
const MAX_AUTO_LOAD = 5;
let autoLoadCount = 0;
const canAutoLoad = (): boolean => autoLoadCount < MAX_AUTO_LOAD;
// 消息新增或内容变化时自动滚动到底部;顶部加载更多期间不滚底。
// immediate:切换会话时 ChatList 会重建,挂载即定位到最新消息,避免停留在顶部旧消息。
@@ -214,8 +223,14 @@ watch(
if (!el) return;
el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
autoLoading = true;
emit('load-more');
if (canAutoLoad()) {
autoLoading = true;
autoLoadCount++;
emit('load-more');
} else {
// 达到自动续载上限:停止自动加载,避免无限循环;用户可主动上滑继续加载更早记录
autoLoading = false;
}
}
});
},
@@ -244,7 +259,13 @@ watch(
// 恢复时须把 scrollTop 设回底部定位到最新消息;若仍不满屏则继续自动续载
el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
emit('load-more');
if (canAutoLoad()) {
autoLoadCount++;
emit('load-more');
} else {
// 达到自动续载上限:停止自动加载,避免无限循环
autoLoading = false;
}
} else {
autoLoading = false;
}
@@ -262,7 +283,9 @@ const handleScroll = (e: Event) => {
const el = chatListRef.value;
if (!el || !props.hasMore || props.loadingMore) return;
if (el.scrollTop <= 4) {
// 用户主动上滑加载更早:重置自动续载计数,使用户操作不受自动续载上限约束
autoLoading = false;
autoLoadCount = 0;
emit('load-more');
}
};
+139 -24
View File
@@ -1,22 +1,62 @@
<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>
<template v-for="(out, i) in outputs" :key="i">
<!-- 图片常驻内嵌缩略图点击放大看大图走父级预览弹窗 -->
<div v-if="out.type === 'image'" class="output-media is-image" @click="emit('preview', out)">
<img :src="resolveUrl ? resolveUrl(out.url) : out.url" :alt="out.name" loading="lazy" />
<span class="media-name">{{ out.name }}</span>
<span class="media-actions" @click.stop>
<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 v-else-if="out.type === 'video'" class="output-media is-video">
<video :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></video>
<span class="media-actions" @click.stop>
<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 v-else-if="out.type === 'audio'" class="output-media is-audio">
<audio :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></audio>
<span class="media-actions" @click.stop>
<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 v-else 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>
</template>
</div>
</template>
@@ -33,6 +73,8 @@ interface WorkflowOutput {
interface Props {
outputs: WorkflowOutput[];
// 相对路径 → 完整 URL 解析函数(由父级传入 buildAssetUrl),缺省时直接用原 url(绝对地址场景)
resolveUrl?: (url: string) => string;
}
interface Emits {
@@ -58,20 +100,76 @@ const getIcon = (type: string) => {
.workflow-output-card {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
margin-top: 10px;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 10px;
padding: 8px;
}
// 媒体产出(图片/视频/音频):常驻内嵌展示,hover 浮出下载/删除操作
.output-media {
position: relative;
overflow: hidden;
border-radius: 10px;
background: #0f172a;
img,
video {
display: block;
width: 100%;
max-height: 320px;
object-fit: contain;
background: #0f172a;
}
audio {
display: block;
width: 100%;
background: #0f172a;
padding: 10px;
}
.media-name {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 4px 8px;
font-size: 12px;
color: #e2e8f0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 2px;
padding: 2px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.75);
opacity: 0;
transition: opacity 0.15s;
}
&:hover {
.media-actions {
opacity: 1;
}
}
}
// 非媒体产出:文件行 + 操作按钮
.output-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
padding: 8px 10px;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 10px;
transition: background 0.15s;
&:hover {
@@ -145,4 +243,21 @@ const getIcon = (type: string) => {
font-size: 13px;
}
}
// 媒体产出内的操作按钮:深色背景上使用浅色图标
.output-media {
.output-action {
color: #cbd5e1;
&:hover {
background: rgba(255, 255, 255, 0.18);
color: #f8fafc;
}
&.is-danger:hover {
background: rgba(220, 38, 38, 0.35);
color: #fecaca;
}
}
}
</style>
+105 -76
View File
@@ -31,6 +31,7 @@
@output-preview="handleOutputPreview"
@output-download="handleOutputDownload"
@output-delete="handleOutputDelete"
:resolve-url="buildAssetUrl"
@workflow-select="handlePlaceWorkflowSelect"
@load-more="handleLoadMore"
/>
@@ -90,7 +91,7 @@ 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, extractWorkflowOutputs } from './utils/flowDsl';
import { applyHomeFormValues, buildOutputsFromUrls, 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';
@@ -432,9 +433,12 @@ const currentSessionResults = computed(() => {
const SESSION_PAGE_SIZE = 10;
const sessionPage = reactive<Record<string, { page: number; total: number; loading: boolean; done: boolean }>>({});
const sessionLoadingMore = reactive<Record<string, boolean>>({});
// 会话是否已有后端正式号:已认领(后端生成的 UUID)→ true;未认领的本地临时会话(virtual_ 前缀)→ false
const hasBackendSessionId = (sid?: string): boolean => !!sid && !String(sid).startsWith('virtual_');
const currentSessionHasMore = computed(() => {
const id = activeHistoryId.value;
return !!id && !!sessionPage[id] && !sessionPage[id].done;
// 未认领后端正式号的本地临时会话无历史记录,不提供加载更多,避免触发自动续载空转
return !!id && hasBackendSessionId(id) && !!sessionPage[id] && !sessionPage[id].done;
});
const currentSessionLoadingMore = computed(() => {
const id = activeHistoryId.value;
@@ -444,8 +448,71 @@ const currentSessionLoadingMore = computed(() => {
// 占位态:无工作流且无消息(MainContent 显示引导页)时隐藏输入框快捷胶囊,避免与占位卡片重复
const isPlaceholder = computed(() => !selectedWorkflowDetail.value && currentMessages.value.length === 0);
const getSessionId = () => {
return `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
// 建连会话号:已认领(后端正式 UUID,非 virtual_ 本地临时号)回传复用;未认领传空让后端创建,首帧 ack 认领
const resolveSessionIdForConnect = (s: any): string => {
const sid = s?.sessionId || '';
return hasBackendSessionId(sid) ? sid : '';
};
// 会话 id 别名:认领后本地临时 id → 后端正式号(historyList 条目已就地更新,这里保留旧→新映射供进行中的轮解析)
const sessionIdAlias: Record<string, string> = {};
const currentIdOf = (sid: string): string => sessionIdAlias[sid] || sid;
// 认领后端正式会话号:把本地临时会话(virtual_ 前缀)就地迁移为后端 UUID,
// 所有以旧 id 为 key 的状态同步迁移,运行中消息流随 activeHistoryId 落到新 key
const claimSessionId = (oldId: string, newId: string) => {
if (!oldId || !newId || oldId === newId) return;
// 历史列表条目就地更新(同对象引用,进行中闭包读到的 id 同步最新)
const item = historyList.value.find((h) => h.id === oldId);
if (item) {
item.id = newId;
item.sessionId = newId;
}
sessionIdAlias[oldId] = newId;
// 消息列表
const msgs = sessionMessages.value.get(oldId);
if (msgs) {
sessionMessages.value.delete(oldId);
sessionMessages.value.set(newId, msgs);
}
// 分页 / 结果状态
if (sessionResultsMap[oldId] !== undefined) {
sessionResultsMap[newId] = sessionResultsMap[oldId];
delete sessionResultsMap[oldId];
}
if (sessionResultsLoading[oldId] !== undefined) {
sessionResultsLoading[newId] = sessionResultsLoading[oldId];
delete sessionResultsLoading[oldId];
}
if (sessionPage[oldId]) {
sessionPage[newId] = sessionPage[oldId];
delete sessionPage[oldId];
}
if (sessionLoadingMore[oldId] !== undefined) {
sessionLoadingMore[newId] = sessionLoadingMore[oldId];
delete sessionLoadingMore[oldId];
}
// 发送中标记 / 活跃会话指向
if (sendingSessions[oldId] !== undefined) {
sendingSessions[newId] = sendingSessions[oldId];
delete sendingSessions[oldId];
}
if (activeHistoryId.value === oldId) activeHistoryId.value = newId;
// 连接状态对齐
if (wsState && wsState.sid === oldId) wsState.sid = newId;
};
// 认领后刷新会话标题:用后端正式号查一次会话列表,把「新会话 N」更新为后端返回的正式名(fire-and-forget
const refreshSessionTitle = async (uuid: string) => {
try {
const sessionRes = await getSessionListV2({ pageNum: 1, pageSize: 10 });
const fresh = getSessionData(sessionRes).find((s: any) => String(s.sessionId) === uuid);
if (!fresh) return;
const item = historyList.value.find((h) => h.id === uuid);
if (item) item.title = fresh.sessionName || '未命名会话';
} catch {
/* 静默:标题保持本地临时名,下次列表刷新自然更新 */
}
};
const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: boolean) => {
@@ -604,6 +671,13 @@ const ensureSocket = async (sid: string, sessionId: string): Promise<WebSocket |
onMessage: (raw) => {
if (wsState?.ws === session?.ws) activeHandler?.onMessage(raw);
},
onClaim: (uuid) => {
// 首次建连:后端在 ack 里返回正式会话号 → 认领本地临时会话,后续所有操作以正式号为 key
if (wsState?.ws === session?.ws) {
claimSessionId(sid, uuid);
void refreshSessionTitle(uuid);
}
},
onError: (ev) => {
if (wsState?.ws === session?.ws) activeHandler?.onError(ev);
},
@@ -689,7 +763,7 @@ const handleSend = async (message: string) => {
curSession.status = 'executing';
const sessionId = curSession.sessionId || getSessionId();
const sessionId = resolveSessionIdForConnect(curSession);
// 工作流执行由表单卡片提交触发(handleFormSubmit);输入框发送恒走普通对话
await runChat(sid, sessionId, message);
@@ -727,7 +801,7 @@ const handleRetry = async (msg: ChatMessage) => {
list.splice(idx, 1);
sendingSessions[sid] = true;
session.status = 'executing';
const sessionId = session.sessionId || getSessionId();
const sessionId = resolveSessionIdForConnect(session);
try {
await runChat(sid, sessionId, question);
} catch {
@@ -820,7 +894,7 @@ const runWorkflow = async (
}
let finished = false;
const finishExec = (success: boolean, errorMsg?: string) => {
const finishExec = (success: boolean, errorMsg?: string, outputs?: WorkflowOutput[]) => {
if (finished) return;
finished = true;
// 定格表单卡片:done/failed + 失败信息;执行结束清进度(避免残留影响重新编辑)
@@ -828,17 +902,18 @@ const runWorkflow = async (
formMsg.formError = success ? undefined : errorMsg || '执行失败';
formMsg.formProgress = undefined;
curSession.status = success ? 'completed' : 'failed';
// 结果消息:成功记录后续异步附加本次执行产出(outputs
// 结果消息:成功时携带本次执行产出(outputs,直接以产出卡片渲染在对话里。
// 产出来源优先 flow_complete 事件自带 resultFileUrlsbuildOutputsFromUrls),异步详情链路作兜底
const resultMsg: ChatMessage = {
id: 'msg-' + Date.now() + (success ? '-done' : '-fail'),
content: success ? '✅ 执行完成,可前往工作空间查看产出' : `${errorMsg || '执行失败,请重试或联系管理员'}`,
content: success ? '✅ 执行完成' : `${errorMsg || '执行失败,请重试或联系管理员'}`,
time: formatTime(new Date()),
isUser: false,
outputs: [],
outputs: outputs || [],
};
addMessage(resultMsg);
if (success) {
ElMessage.success('✅ 执行完成,可前往工作空间查看');
ElMessage.success('✅ 执行完成');
// 刷新工作空间树(查询所有结果)
getExecutionList().then((res) => {
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
@@ -846,6 +921,7 @@ const runWorkflow = async (
});
// 会话内渲染本次产出:拉最新 session/get 记录(时间倒序首条)→ getExecutionDetail → 附加到结果消息
// 时序降级:记录未及时写入则静默跳过(用户重进会话可见回显产出)
// 会话在建连时已认领为后端正式号,直接按认领后的 sid 查询本次执行记录
loadSessionResults(sid).then((results) => {
const latest = Array.isArray(results) && results.length ? results[0] : undefined;
if (latest && latest.type === 'workflow' && !latest.errorMsg) {
@@ -860,36 +936,8 @@ const runWorkflow = async (
} else {
ElMessage.error(errorMsg || '执行失败,请重试');
}
// 虚拟会话执行后刷新会话列表替换真实条目
if (curSession.id.startsWith('virtual_')) {
getSessionListV2({ pageNum: 1, pageSize: 10 }).then((sessionRes) => {
const freshList = getSessionData(sessionRes)
.filter((s: any) => s.sessionId)
.map((s: any) => ({
id: String(s.sessionId),
sessionId: String(s.sessionId),
title: s.sessionName || '未命名会话',
time: s.createdAt?.substring(0, 10) || '',
}));
const match = freshList.find((s: any) => s.sessionId === curSession.sessionId);
if (match) {
const idx = historyList.value.findIndex((h) => h.id === curSession.id);
if (idx >= 0) {
const msgs = sessionMessages.value.get(curSession.id);
sessionMessages.value.delete(curSession.id);
sessionMessages.value.set(match.id, msgs || []);
historyList.value[idx] = { ...match, status: curSession.status };
if (activeHistoryId.value === curSession.id) {
activeHistoryId.value = match.id;
}
}
loadSessionResults(match.id);
}
});
} else {
// 非虚拟会话:执行完成/失败后直接刷新会话结果
loadSessionResults(sid);
}
// 会话在建连时已认领为后端正式号(UUID),执行完成/失败后直接刷新会话结果
loadSessionResults(sid);
delete sendingSessions[sid];
// 执行完成后切回对话页:清空工作流选择,主区域展示消息流
if (success) {
@@ -920,7 +968,11 @@ const runWorkflow = async (
if (msg) {
if (msg.type === 'error') {
finishExec(false, getErrorText(msg));
} else if (msg.type === 'flow_complete') {
// flow_complete 为后端新增的工作流完成信号,事件自带产出文件 URL 列表 → 直接渲染产出卡片
finishExec(true, undefined, buildOutputsFromUrls(msg?.data?.resultFileUrls));
} else if (msg.type === 'answer') {
// answer 为最终回答完成信号(无产出文件)
finishExec(true);
} else {
// 工作流节点进度推进:node_start / node_complete → 更新表单卡片运行态进度文本
@@ -961,6 +1013,9 @@ const runWorkflow = async (
finishExec(false, 'WebSocket 初始化失败,请检查服务地址');
return;
}
// 首次建连已认领后端正式号(ensureSocket 就绪前 ack 已处理):本轮 sid 更新为正式号,
// 后续状态操作(消息/分页/结果)都落到新 key
sid = currentIdOf(sid);
if (activeHandler !== handler) return; // 等待期被终止/切走 → 不再发送启动帧
activeRunIsWorkflow.value = true;
sendWorkflowStart(ws, {
@@ -996,7 +1051,7 @@ const handleFormSubmit = async (
msg.formError = undefined;
session.status = 'executing';
const sessionId = session.sessionId || getSessionId();
const sessionId = resolveSessionIdForConnect(session);
await runWorkflow(sid, sessionId, msg.form, payload, msg);
};
@@ -1151,36 +1206,8 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
// 更新会话状态
const session = historyList.value.find((h) => h.id === sid);
if (session) session.status = success ? 'completed' : 'failed';
// 虚拟会话对话后刷新会话列表替换真实条目
if (session && session.id.startsWith('virtual_')) {
getSessionListV2({ pageNum: 1, pageSize: 10 }).then((sessionRes) => {
const freshList = getSessionData(sessionRes)
.filter((s: any) => s.sessionId)
.map((s: any) => ({
id: String(s.sessionId),
sessionId: String(s.sessionId),
title: s.sessionName || '未命名会话',
time: s.createdAt?.substring(0, 10) || '',
}));
const match = freshList.find((s: any) => s.sessionId === session.sessionId);
if (match) {
const idx = historyList.value.findIndex((h) => h.id === session.id);
if (idx >= 0) {
const msgs = sessionMessages.value.get(session.id);
sessionMessages.value.delete(session.id);
sessionMessages.value.set(match.id, msgs || []);
historyList.value[idx] = { ...match, status: session.status };
if (activeHistoryId.value === session.id) {
activeHistoryId.value = match.id;
}
}
loadSessionResults(match.id);
}
});
} else {
// 非虚拟会话:对话完成/失败后直接刷新会话结果
loadSessionResults(sid);
}
// 会话在建连时已认领为后端正式号(UUID),对话完成/失败后直接刷新会话结果
loadSessionResults(sid);
delete sendingSessions[sid];
};
@@ -1299,6 +1326,8 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
finishChat(false, 'WebSocket 初始化失败,请检查服务地址');
return;
}
// 首次建连已认领后端正式号(ensureSocket 就绪前 ack 已处理):本轮 sid 更新为正式号
sid = currentIdOf(sid);
if (activeHandler !== handler) return; // 等待期被终止/切走 → 不再发送启动帧
sendAgentStart(ws, { modelId: chatModelId, question: message });
};
@@ -1330,8 +1359,8 @@ const handleSelectHistory = async (id: string) => {
if (session?.status === 'completed' || session?.status === 'failed' || (session?.status === 'executing' && !sendingSessions[session.id])) {
session.status = undefined;
}
// 真实会话打开即预建连(fire-and-forget);虚拟会话首次提问才建连
if (session && !session.sessionId.startsWith('virtual_')) {
// 已认领后端正式号的会话打开即预建连(fire-and-forget);未认领的本地临时会话首次提问才建连
if (session && hasBackendSessionId(session.sessionId)) {
void ensureSocket(id, session.sessionId);
}
};
@@ -1533,8 +1562,8 @@ const handleDeleteHistory = async (id: string) => {
} else if (wsState?.sid === id) {
closeCurrentSocket();
}
const isVirtual = id.startsWith('virtual_');
if (!isVirtual) {
// 已认领后端正式号的会话才调后端删除接口;未认领的本地临时会话后端无记录,跳过删除
if (hasBackendSessionId(id)) {
try {
await deleteSessionV2(id);
} catch {
+23
View File
@@ -194,3 +194,26 @@ export function extractWorkflowOutputs(data: any, backendId?: string): WorkflowO
if (data.resultUrl) push(data.resultUrl);
return outputs;
}
// 从事件携带的文件 URL 数组(flow_complete 的 data.resultFileUrls)直接构建产出列表:
// 不依赖执行详情的异步查询,type 同样按扩展名推断,去重后归一为 { url, name, type, backendId }。
// 用于完成事件即时渲染产出卡片,删除时 backendId 为空则跳过删除接口(安全降级)。
export function buildOutputsFromUrls(urls: any, backendId?: string): WorkflowOutput[] {
if (!Array.isArray(urls)) return [];
const seen = new Set<string>();
const outputs: WorkflowOutput[] = [];
for (const u of urls) {
if (typeof u !== 'string') continue;
const url = u.trim();
if (!url || seen.has(url)) continue;
seen.add(url);
const name = String(url).split('?')[0].split('/').pop() || '产出文件';
outputs.push({
url,
name,
type: guessFileType(url),
backendId: backendId || '',
});
}
return outputs;
}
+52 -4
View File
@@ -19,10 +19,13 @@ export interface SessionSocket {
}
export interface ConnectSessionSocketOptions {
sessionId: string;
// 可选:已认领会话(后端正式号)传回复用;未传(空串)则后端自动创建,从首帧 ack 认领
sessionId?: string;
modelId?: string | number;
flowId?: string | number;
onMessage: (raw: any) => void;
// 未传 sessionId 时,后端创建会话后首帧 ack 返回的正式会话号(UUID),调用方据此认领本地会话
onClaim?: (sessionId: string) => void;
onClose: (ev: CloseEvent) => void;
onError: (ev: Event) => void;
}
@@ -35,7 +38,10 @@ export interface ConnectSessionSocketOptions {
*/
export function connectSessionSocket(opts: ConnectSessionSocketOptions): SessionSocket | null {
const base = getWsBase();
const params: Record<string, string> = { sessionId: opts.sessionId };
const params: Record<string, string> = {};
// 已认领会话传号复用;未传号则由后端自动创建会话,首帧 ack 返回正式号
const sidForConnect = opts.sessionId;
if (sidForConnect) params.sessionId = sidForConnect;
if (opts.flowId != null) params.flowId = String(opts.flowId);
if (opts.modelId != null) params.modelId = String(opts.modelId);
const token = Session.get('token');
@@ -61,16 +67,42 @@ export function connectSessionSocket(opts: ConnectSessionSocketOptions): Session
readyReject = reject;
});
// 未传会话号:等后端首帧 ackdata.sessionId)认领后才算就绪,避免业务帧先于会话号发出
const wantsClaim = !opts.sessionId;
let claimed = false;
// 打开前是否已就绪:用于区分「未连上就关闭」与「正常关闭」
let opened = false;
// 兜底:ack 迟迟未到(异常)时超时放行,降级用原本地 id 继续,避免永久卡在建连
let claimTimer: number | null = null;
ws.onopen = () => {
// 长连接:握手成功仅标记就绪,不发送任何启动帧
opened = true;
readyResolve(ws);
if (!wantsClaim) {
readyResolve(ws);
} else {
claimTimer = window.setTimeout(() => {
claimed = true;
readyResolve(ws);
}, 5000);
}
};
ws.onmessage = (ev) => {
// 首次建连:首帧 ack 携带后端正式会话号 → 认领后放行业务帧
if (wantsClaim && !claimed) {
const ackId = parseAckSessionId(ev.data);
if (ackId) {
claimed = true;
if (claimTimer != null) window.clearTimeout(claimTimer);
opts.onClaim?.(ackId);
readyResolve(ws);
return; // ack 帧仅用于认领,不交给业务 handler
}
}
opts.onMessage(ev.data);
};
ws.onmessage = (ev) => opts.onMessage(ev.data);
ws.onclose = (ev) => {
if (claimTimer != null) window.clearTimeout(claimTimer);
if (!opened) readyReject(ev);
opts.onClose(ev);
};
@@ -79,6 +111,22 @@ export function connectSessionSocket(opts: ConnectSessionSocketOptions): Session
return { ws, ready };
}
// 解析建连 ack 帧:{ type:'ack', data:{ sessionId } } → 返回 sessionId;非 ack / 无号返回空串
function parseAckSessionId(raw: any): string {
let data: any = raw;
if (typeof raw === 'string') {
try {
data = JSON.parse(raw);
} catch {
return '';
}
}
if (!data || typeof data !== 'object') return '';
if (data.type !== 'ack') return '';
const sid = data?.data?.sessionId;
return typeof sid === 'string' && sid ? sid : '';
}
// ===== 启动帧 =====
/** 普通对话:发送提问(type=agent),后端据此开始推流 */
+4 -3
View File
@@ -30,6 +30,7 @@ export const WsEventType = {
ReasoningChunk: 'reasoning_chunk', // 思考内容增量(逐 chunk
NodeStart: 'node_start', // 工作流节点开始(进度推进)
NodeComplete: 'node_complete', // 工作流节点完成(进度推进)
FlowComplete: 'flow_complete', // 工作流执行完成(后端完成信号,日志末尾事件)
Error: 'error', // 出错
} as const;
@@ -108,11 +109,11 @@ export function getToolResultText(msg: WsStreamMessage): string {
return '';
}
/** error → 错误文案(error 字段为详细原因优先;其次 message;再 data.message / data.error / data.msg */
/** error → 错误文案(message 优先展示简洁业务提示;error 详细原因仅在 message 缺失时兜底 */
export function getErrorText(msg: WsStreamMessage): string {
// 详细失败原因(含节点名/具体错误),优先展示
if (typeof msg?.error === 'string' && msg.error) return msg.error;
// 只显示 message 的消息(用户确认),error 详细原因不再作为界面展示内容
if (typeof msg?.message === 'string' && msg.message) return msg.message;
if (typeof msg?.error === 'string' && msg.error) return msg.error;
const d = msg?.data;
if (typeof d === 'string') return d;
if (d && typeof d === 'object') {