修改会话的bug和回显以及滚动加载

This commit is contained in:
2026-08-14 21:41:32 +08:00
parent 315d05c3ea
commit a65b898680
6 changed files with 320 additions and 137 deletions
+1 -1
View File
@@ -5,4 +5,4 @@ ENV = 'development'
# 开发环境走本地代理,避免 CORS
VITE_API_URL = 'http://116.204.74.41:8000'
# VITE_API_URL = 'http://192.168.3.30:8000'
# VITE_API_URL = 'http://192.168.3.135:8000'
# VITE_API_URL = 'http://192.168.3.135:8000'
+11 -1
View File
@@ -19,7 +19,7 @@ export interface VOSessionInfoResult {
status: number; // 1-运行中, 2-成功, 3-失败
flowId: string;
requestParams: Record<string, any>;
resultFileUrl: string;
resultContent: string; // 回复文本内容(非文件地址)
totalTokens: number;
totalFee: number;
errorMsg: string;
@@ -52,3 +52,13 @@ export function deleteSessionV2(id: string, requestOptions?: RequestOptions) {
requestOptions,
});
}
// 删除会话内的对话记录(AI 回复 / 用户问题单条或多条),传 Ids 数组,每项为 { id, type }
export function deleteSessionRecord(ids: { id: number; type: string }[], requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/deleteRecord',
method: 'post',
data: { ids },
requestOptions,
});
}
+120 -6
View File
@@ -1,5 +1,10 @@
<template>
<div ref="chatListRef" class="chat-list">
<div ref="chatListRef" class="chat-list" @scroll.passive="handleScroll">
<!-- 顶部有更多历史时上滑加载更早记录 -->
<div v-if="hasMore || loadingMore" class="load-more-bar">
<span v-if="loadingMore" class="load-more-spinner"></span>
{{ loadingMore ? '加载中' : '继续上滑加载更早的记录' }}
</div>
<div
v-for="msg in messages"
:key="msg.id"
@@ -46,6 +51,9 @@
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
<el-icon><DocumentCopy /></el-icon>
</button>
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
<el-icon><Delete /></el-icon>
</button>
</div>
<div class="time">{{ msg.time }}</div>
</div>
@@ -54,9 +62,9 @@
</template>
<script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue';
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
import { Delete, DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
import { renderMarkdown } from '../utils/markdown';
@@ -69,18 +77,28 @@ interface ChatMessage {
thinking?: string;
thinkingSeconds?: number;
retryQuestion?: string;
// 后端记录 id / 类型(来自 session/get 的每条结果),存在时才显示删除按钮
recordId?: string;
recordType?: string;
}
interface Emits {
(e: 'retry', msg: ChatMessage): void;
(e: 'regenerate', msg: ChatMessage): void;
(e: 'delete', msg: ChatMessage): void;
(e: 'load-more'): void;
}
interface Props {
messages: ChatMessage[];
hasMore?: boolean;
loadingMore?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
hasMore: false,
loadingMore: false,
});
const emit = defineEmits<Emits>();
const chatListRef = ref<HTMLElement | null>(null);
@@ -135,16 +153,82 @@ const thinkingTitle = (msg: ChatMessage): string => {
return msg.thinkingSeconds != null ? `已深度思考(用时 ${msg.thinkingSeconds} 秒)` : '已深度思考';
};
// 消息新增或内容变化时自动滚动到底部
// autoLoading 标记本次 load-more 是否由"自动续载"触发:自动续载不保持位置(落在最新),用户上滑保持视口
let autoLoading = false;
// 消息新增或内容变化时自动滚动到底部;顶部加载更多期间不滚底。
// immediate:切换会话时 ChatList 会重建,挂载即定位到最新消息,避免停留在顶部旧消息。
// 首屏不满视口时自动续载更早记录,直到填满视口或没有更多(保证"上滑加载"入口始终可见)
watch(
() => props.messages.map((m) => `${m.id}|${m.content}|${m.thinking || ''}|${m.loading ? 1 : 0}`).join('\n'),
() => {
if (props.loadingMore) return;
nextTick(() => {
const el = chatListRef.value;
if (el) el.scrollTop = el.scrollHeight;
if (!el) return;
el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
autoLoading = true;
emit('load-more');
}
});
},
{ immediate: true }
);
// 顶部加载更多:开始加载时记录容器高度,完成后把滚动位置补回新增高度,保持视口内消息不跳动。
// 该 watch 注册在 messages watch 之后,其 nextTick 回调后执行,可覆盖同批的"滚到底"。
const prevScrollHeight = ref(0);
watch(
() => props.loadingMore,
(loading, prev) => {
if (loading && !prev) {
const el = chatListRef.value;
prevScrollHeight.value = autoLoading ? 0 : el && el.scrollHeight > el.clientHeight ? el.scrollHeight : 0;
} else if (!loading && prev) {
nextTick(() => {
const el = chatListRef.value;
if (!el) return;
if (prevScrollHeight.value) {
// 用户主动上滑加载更早:滚动位置补回新增高度,保持视口内消息不跳动
el.scrollTop = el.scrollHeight - prevScrollHeight.value;
prevScrollHeight.value = 0;
} else if (autoLoading) {
// 自动续载:加载期间滚底 watch 被 loadingMore 拦截(同批 loadingMore 仍为 true 先 flush),
// 恢复时须把 scrollTop 设回底部定位到最新消息;若仍不满屏则继续自动续载
el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
emit('load-more');
} else {
autoLoading = false;
}
}
});
}
}
);
// 滚动到顶部时触发加载更早记录(防重入由父组件 sessionLoadingMore 保证)
const handleScroll = (e: Event) => {
// 程序设置 scrollTop 触发的 scroll 事件(isTrusted=false)不参与交互判断:
// 自动续载/滚底时 scrollTop 不满屏被钳制为 0,会被误判为"用户上滑"而清空 autoLoading、破坏自动定位到最新
if (!e.isTrusted) return;
const el = chatListRef.value;
if (!el || !props.hasMore || props.loadingMore) return;
if (el.scrollTop <= 4) {
autoLoading = false;
emit('load-more');
}
};
onMounted(() => {
const el = chatListRef.value;
// 挂载即定位到最新消息。immediate watch 的 nextTick 在 Transition 期间 DOM 未插入时执行(el 为 null)会失效,
// 这里在 DOM 已插入、内容已渲染(sh 已确定)后滚底;requestAnimationFrame 确保过渡与布局完成
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight;
});
});
</script>
<style scoped lang="scss">
@@ -159,6 +243,31 @@ watch(
&::-webkit-scrollbar { display: none; }
}
/* 顶部:加载更早记录提示条 */
.load-more-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-shrink: 0;
font-size: 11px;
color: #cbd5e1;
padding: 4px 0 10px;
}
.load-more-spinner {
width: 12px;
height: 12px;
border: 2px solid #e2e8f0;
border-top-color: #94a3b8;
border-radius: 50%;
animation: load-more-rotate 0.7s linear infinite;
}
@keyframes load-more-rotate {
to { transform: rotate(360deg); }
}
.message-row {
display: flex;
align-items: flex-start;
@@ -254,6 +363,11 @@ watch(
color: #4b5563;
}
&.is-danger:hover {
background: #fef2f2;
color: #dc2626;
}
.el-icon {
font-size: 14px;
}
+16 -6
View File
@@ -130,20 +130,26 @@
</div>
</div>
<!-- 对话页 — 无工作流但有消息或会话结果时展示:结果卡片(仅工作流结果时显示)+ 消息流(下) -->
<div v-else-if="hasMessages || hasResults" :key="'chat'" class="content-body chat-body">
<!-- 对话页 — 无工作流但有消息或会话结果时展示:结果卡片(仅工作流结果时显示)+ 消息流(下)
key 绑定会话 id:切换会话时重建内容区,触发 Transition 过渡动画(固定 key 会复用 DOM 不触发) -->
<div v-else-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
<SessionResults
v-if="hasWorkflowResults"
:results="results"
:loading="resultsLoading"
@preview="emit('preview-result', $event)"
@download="emit('download-result', $event)"
:has-more="hasMore"
:loading-more="loadingMore"
@load-more="emit('load-more', activeHistoryId)"
/>
<ChatList
:messages="messages"
:has-more="hasMore"
:loading-more="loadingMore"
class="chat-list-scroll"
@retry="emit('retry', $event)"
@regenerate="emit('regenerate', $event)"
@delete="emit('delete', $event)"
@load-more="emit('load-more', activeHistoryId)"
/>
</div>
@@ -192,14 +198,16 @@ interface Props {
messages?: any[];
results?: VOSessionInfoResult[];
resultsLoading?: boolean;
hasMore?: boolean;
loadingMore?: boolean;
}
interface Emits {
(e: 'retry', msg: any): void;
(e: 'regenerate', msg: any): void;
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
(e: 'preview-result', url: string): void;
(e: 'download-result', url: string): void;
(e: 'delete', msg: any): void;
(e: 'load-more', sid: string | null | undefined): void;
}
const props = withDefaults(defineProps<Props>(), {
@@ -207,6 +215,8 @@ const props = withDefaults(defineProps<Props>(), {
messages: () => [],
results: () => [],
resultsLoading: false,
hasMore: false,
loadingMore: false,
});
const emit = defineEmits<Emits>();
+36 -71
View File
@@ -1,5 +1,5 @@
<template>
<div class="session-results">
<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>
@@ -25,45 +25,53 @@
<!-- 问题 / 参数摘要 -->
<div v-if="getQuestionText(r)" class="result-question">{{ getQuestionText(r) }}</div>
<!-- AI 回复resultFileUrl 指向的 txt 内容进入自动读取 -->
<div v-if="r.resultFileUrl" class="result-reply">
<div v-if="getReplyState(r)?.loading" class="result-reply-tip">加载回复中</div>
<div v-else-if="getReplyState(r)?.error" class="result-reply-tip is-error">回复加载失败</div>
<div v-else-if="getReplyState(r)?.text" class="result-reply-text">{{ getReplyState(r)?.text }}</div>
<div v-else class="result-reply-tip">暂无回复内容</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 || r.resultFileUrl" class="result-sub">
<span v-if="r.errorMsg" class="result-error"> {{ r.errorMsg }}</span>
<div v-if="r.type === 'workflow' && r.resultFileUrl" class="result-actions">
<button type="button" class="result-action" @click="emit('preview', r.resultFileUrl)">预览</button>
<button type="button" class="result-action" @click="emit('download', r.resultFileUrl)">下载</button>
</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 { reactive, watch } from 'vue';
import { getUpFileUrl } from '/@/utils/gfast';
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: 'preview', url: string): void;
(e: 'download', url: string): void;
(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');
};
// 状态键:1-运行中, 2-成功, 3-失败
const statusKey = (status: number): string => {
@@ -93,31 +101,6 @@ const getQuestionText = (r: VOSessionInfoResult): string => {
return '';
};
// 结果卡片回复内容:进入自动 fetch resultFileUrl 指向的 txt 文件并展示
const replyMap = reactive<Record<string, { loading: boolean; text: string; error: boolean }>>({});
const getReplyState = (r: VOSessionInfoResult) => replyMap[r.id];
const loadReply = async (r: VOSessionInfoResult) => {
if (!r.resultFileUrl) return;
if (replyMap[r.id] && (replyMap[r.id].loading || replyMap[r.id].text)) return; // 加载中/已有内容不重复请求
replyMap[r.id] = { loading: true, text: '', error: false };
try {
const res = await fetch(getUpFileUrl(r.resultFileUrl));
if (!res.ok) throw new Error('HTTP ' + res.status);
const text = await res.text();
replyMap[r.id] = { loading: false, text, error: false };
} catch {
replyMap[r.id] = { loading: false, text: '', error: true };
}
};
watch(
() => props.results,
(list) => {
(list || []).forEach((r) => loadReply(r));
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
@@ -160,6 +143,13 @@ watch(
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;
@@ -256,29 +246,11 @@ watch(
word-break: break-all;
}
.result-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
.result-error {
font-size: 12px;
color: #dc2626;
word-break: break-all;
}
.result-action {
border: 1px solid #e2e8f0;
background: #fff;
color: #3b82f6;
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #eff6ff;
border-color: #bfdbfe;
}
}
/* 问题/参数摘要 */
.result-question {
font-size: 13px;
font-weight: 600;
@@ -307,11 +279,4 @@ watch(
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
}
.result-reply-tip {
font-size: 12px;
color: #94a3b8;
padding: 4px 0;
&.is-error { color: #dc2626; }
}
</style>
+136 -52
View File
@@ -23,11 +23,13 @@
:messages="currentMessages"
:results="currentSessionResults"
:results-loading="currentSessionResultsLoading"
:has-more="currentSessionHasMore"
:loading-more="currentSessionLoadingMore"
@retry="handleRetry"
@regenerate="handleRetry"
@preview-result="handlePreviewResult"
@download-result="handleDownloadResult"
@delete="handleDeleteMessage"
@workflow-select="handlePlaceWorkflowSelect"
@load-more="handleLoadMore"
/>
<InputBar
ref="inputBarRef"
@@ -85,9 +87,8 @@ import {
deleteExecutionResult,
downloadToFile,
} from '/@/api/settings/creation';
import { getSessionListV2, getSessionResults, deleteSessionV2 } from '/@/api/settings/workflow/session';
import { getSessionListV2, getSessionResults, deleteSessionV2, deleteSessionRecord } from '/@/api/settings/workflow/session';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
import { getUpFileUrl } from '/@/utils/gfast';
interface HistoryItem {
id: string;
@@ -106,6 +107,9 @@ interface ChatMessage {
thinking?: string;
thinkingSeconds?: number;
retryQuestion?: string;
// 后端记录 id / 类型(来自 session/get 的每条结果),用于删除对话记录
recordId?: string;
recordType?: string;
}
interface TreeNode {
@@ -175,26 +179,6 @@ const previewNode = (data: TreeNode) => {
previewDialogVisible.value = true;
};
const getUrlExt = (url: string): string => {
const m = String(url || '').split('?')[0].toLowerCase().match(/\.([a-z0-9]+)$/);
return m ? m[1] : '';
};
// 会话结果文件预览(复用预览弹窗)
const handlePreviewResult = (url: string) => {
if (!url) return;
previewUrl.value = buildAssetUrl(url);
previewMode.value = getPreviewMode(getUrlExt(url));
previewDialogVisible.value = true;
};
// 会话结果文件下载
const handleDownloadResult = (url: string) => {
if (!url) return;
const name = String(url).split('/').pop()?.split('?')[0] || 'result';
downloadAsset(url, name);
};
// 通用文件下载:优先后端代理,兜底 a 标签
const downloadAsset = async (url: string, name: string) => {
try {
@@ -360,6 +344,19 @@ const currentSessionResultsLoading = computed(() => {
return !!id && !!sessionResultsLoading[id];
});
// session/get 分页:打开会话详情默认只拿最近 3 条对话,更早的通过滚动加载更多(下一页更早的记录)
const SESSION_PAGE_SIZE = 3;
const sessionPage = reactive<Record<string, { page: number; total: number; loading: boolean; done: boolean }>>({});
const sessionLoadingMore = reactive<Record<string, boolean>>({});
const currentSessionHasMore = computed(() => {
const id = activeHistoryId.value;
return !!id && !!sessionPage[id] && !sessionPage[id].done;
});
const currentSessionLoadingMore = computed(() => {
const id = activeHistoryId.value;
return !!id && !!sessionLoadingMore[id];
});
// 占位态:无工作流且无消息(MainContent 显示引导页)时隐藏输入框快捷胶囊,避免与占位卡片重复
const isPlaceholder = computed(() => !selectedWorkflowDetail.value && currentMessages.value.length === 0);
@@ -518,7 +515,58 @@ const handleRetry = async (msg: ChatMessage) => {
}
};
// ===== 停止生成:发送 workflow_cancel 并关闭连接,保留已生成内容 =====
// ===== 删除会话内对话记录:删除选中的 AI 回复 + 紧邻其上方的用户问题(同一 record 时去重)=====
const handleDeleteMessage = async (msg: ChatMessage) => {
const sid = activeHistoryId.value;
if (!sid) return;
const list = sessionMessages.value.get(sid) || [];
const idx = list.findIndex((m) => m.id === msg.id);
if (idx < 0) return;
// 收集后端记录 idtype 传记录类型:chat/workflow
const ids: { id: number; type: string }[] = [];
const pushId = (m: ChatMessage | undefined | null) => {
if (!m?.recordId) return;
const id = Number(m.recordId);
if (!Number.isFinite(id)) return;
if (ids.some((d) => d.id === id)) return;
ids.push({ id, type: m.recordType || 'chat' });
};
pushId(msg);
// 向上找最近一条用户消息,一并删除(同一后端记录时自动去重)
for (let i = idx - 1; i >= 0; i--) {
if (list[i].isUser) {
pushId(list[i]);
break;
}
}
if (ids.length === 0) {
ElMessage.warning('该对话记录尚未生成(执行中),可刷新后再删除');
return;
}
try {
await ElMessageBox.confirm('确定删除该对话记录?此操作不可恢复。', '确认删除', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
});
} catch {
return;
}
try {
await deleteSessionRecord(ids);
ElMessage.success('已删除');
// 删除成功后重新拉取会话结果,消息流与结果卡片同步
const results = await loadSessionResults(sid);
loadSessionMessagesFromResults(sid, results);
} catch {
// 后端已提示错误,保持原状
}
};
// ===== 停止生成:按执行类型发送对应取消消息并关闭连接,保留已生成内容 =====
// 普通对话走 agent 协议(启动为 {type:'agent'},取消为 {type:'agent_cancel'});
// 工作流走 workflow 协议(取消为 {type:'workflow_cancel'})。
// 判断依据与 handleSend 的分支一致:selectedWorkflowDetail 有值 → 工作流,无值 → 普通对话。
const handleStopGenerate = () => {
const sid = activeHistoryId.value;
if (!sid || !sendingSessions[sid]) return;
@@ -526,7 +574,8 @@ const handleStopGenerate = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
cancelledWs.add(ws);
try {
ws.send(JSON.stringify({ type: 'workflow_cancel' }));
const isWorkflow = !!selectedWorkflowDetail.value;
ws.send(JSON.stringify({ type: isWorkflow ? 'workflow_cancel' : 'agent_cancel' }));
} catch {
/* 连接异常时忽略,直接关闭 */
}
@@ -949,7 +998,7 @@ const handleSelectHistory = async (id: string) => {
selectedWorkflowDetail.value = null;
isHistoryWorkflow.value = false;
inputBarRef.value?.clearWorkflow?.();
await loadSessionMessagesFromResults(id, results);
loadSessionMessagesFromResults(id, results);
}
// 清除可重新执行的标记
if (session?.status === 'completed' || session?.status === 'failed' || (session?.status === 'executing' && !sendingSessions[session.id])) {
@@ -957,55 +1006,88 @@ const handleSelectHistory = async (id: string) => {
}
};
// 加载会话内结果(session/get):workflow+chat 混排,按时间倒序;返回结果列表供判断会话类型
// 加载会话内结果(session/get 第一页):workflow+chat 混排,按时间倒序
const loadSessionResults = async (sid: string) => {
if (!sid) return [];
sessionPage[sid] = { page: 0, total: 0, loading: true, done: false };
sessionResultsLoading[sid] = true;
try {
const res: any = await getSessionResults(sid);
const res: any = await getSessionResults(sid, { pageNum: 1, pageSize: SESSION_PAGE_SIZE });
const list = Array.isArray(res?.data?.list) ? res.data.list : Array.isArray(res?.data) ? res.data : [];
const total = Number(res?.data?.total) || 0;
// done:无数据 / 不足一页 / 已一次拿全(后端可能不分页、一次性返回全部,此时不应再触发加载更多)
sessionPage[sid] = { page: 1, total, loading: false, done: total === 0 || list.length < SESSION_PAGE_SIZE || list.length >= total };
sessionResultsMap[sid] = list;
return list;
} catch {
if (!sessionResultsMap[sid]) sessionResultsMap[sid] = [];
sessionPage[sid] = { page: 0, total: 0, loading: false, done: true };
return sessionResultsMap[sid] || [];
} finally {
sessionResultsLoading[sid] = false;
}
};
// 普通对话会话回显:把 session/get 的 chat 结果转换为「问题(用户) + 回复(AI)」消息流,正常对话气泡展示
const loadSessionMessagesFromResults = async (sid: string, results: VOSessionInfoResult[]) => {
// 把 chat 结果(时间倒序)转成消息流(正序:旧→新)。
// resultContent 字段即回复文本,直接作为 AI 回复展示,无需再读任何文件。
const resultsToMessages = (results: VOSessionInfoResult[]): ChatMessage[] => {
const chats = (results || []).filter((r) => r.type === 'chat');
const msgs: ChatMessage[] = [];
const pending: { msg: ChatMessage; url: string }[] = [];
// 结果按时间倒序 → 反转成正序,再按记录生成问答
[...chats].reverse().forEach((r) => {
const q = r.requestParams?.question;
if (q) {
msgs.push({ id: 'rq-' + r.id, content: String(q), time: r.createdAt || '', isUser: true });
msgs.push({ id: 'rq-' + r.id, content: String(q), time: r.createdAt || '', isUser: true, recordId: String(r.id), recordType: r.type });
}
if (r.resultFileUrl) {
const ai: ChatMessage = { id: 'ra-' + r.id, content: '加载中…', time: r.createdAt || '', isUser: false, loading: true };
msgs.push(ai);
pending.push({ msg: ai, url: r.resultFileUrl });
if (r.resultContent) {
msgs.push({ id: 'ra-' + r.id, content: String(r.resultContent), time: r.createdAt || '', isUser: false, recordId: String(r.id), recordType: r.type });
}
});
return msgs;
};
// 普通对话会话回显:把会话结果转成正常对话消息流(resultContent 即回复文本)
const loadSessionMessagesFromResults = (sid: string, results: VOSessionInfoResult[]) => {
const msgs = resultsToMessages(results);
sessionMessages.value.set(sid, msgs);
// 异步读取 txt 填充 AI 回复
await Promise.all(
pending.map(async ({ msg, url }) => {
try {
const res = await fetch(getUpFileUrl(url));
if (!res.ok) throw new Error('HTTP ' + res.status);
msg.content = await res.text();
} catch {
msg.content = '(回复加载失败)';
} finally {
msg.loading = false;
}
})
);
};
// 加载更多(下一页更早的结果):普通对话插入消息流头部,工作流结果追加到列表尾部
const loadMoreSessionResults = async (sid: string) => {
const st = sessionPage[sid];
if (!st || st.loading || st.done) return;
const nextPage = st.page + 1;
st.loading = true;
try {
const res: any = await getSessionResults(sid, { pageNum: nextPage, pageSize: SESSION_PAGE_SIZE });
const list = Array.isArray(res?.data?.list) ? res.data.list : Array.isArray(res?.data) ? res.data : [];
const total = Number(res?.data?.total) || st.total || 0;
st.page = nextPage;
st.total = total;
st.done = list.length === 0 || nextPage * SESSION_PAGE_SIZE >= total;
sessionResultsMap[sid] = [...(sessionResultsMap[sid] || []), ...list];
// 普通对话:更早的记录转成消息流插入头部
const msgs = sessionMessages.value.get(sid);
if (msgs) {
const older = resultsToMessages(list);
sessionMessages.value.set(sid, [...older, ...msgs]);
}
return sessionResultsMap[sid];
} catch {
st.done = true;
return sessionResultsMap[sid] || [];
} finally {
st.loading = false;
}
};
const handleLoadMore = async (sid: string | null | undefined) => {
if (!sid || sessionLoadingMore[sid]) return;
sessionLoadingMore[sid] = true;
try {
await loadMoreSessionResults(sid);
} finally {
sessionLoadingMore[sid] = false;
}
};
@@ -1054,6 +1136,8 @@ const handleDeleteHistory = async (id: string) => {
sessionMessages.value.delete(id);
sessionResultsMap[id] = [];
delete sessionResultsLoading[id];
delete sessionPage[id];
delete sessionLoadingMore[id];
if (activeHistoryId.value === id) {
if (historyList.value.length > 0) {
activeHistoryId.value = historyList.value[0].id;