首页工作流会话重构与产出内嵌:后端 UUID 认领 + 产出对话内直接预览
会话机制:
- 建连不传本地会话号,由后端生成 UUID 并经首帧 ack 认领(claimSessionId 就地迁移全部状态)
- 删除执行完成后轮询会话列表替换本地条目的双路径逻辑,统一认领后刷新
- 清理 virtual_ 特判,收敛为 hasBackendSessionId 单一语义判断
产出展示:
- flow_complete 事件自带 resultFileUrls 直接构建产出卡片(buildOutputsFromUrls)
- 完成文案改为「✅ 执行完成」,产出以消息形式展示在对话里
- 产出媒体(图片/视频/音频)常驻内嵌预览,图片点击放大,text/file 保持文件行
- ChatList 自动续载增加次数上限,防止 hasMore 恒真时无限加载循环
This commit is contained in:
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 事件自带 resultFileUrls(buildOutputsFromUrls),异步详情链路作兜底
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
// 未传会话号:等后端首帧 ack(data.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),后端据此开始推流 */
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user