修改会话的bug和回显以及滚动加载
This commit is contained in:
+1
-1
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
// 收集后端记录 id(type 传记录类型: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;
|
||||
|
||||
Reference in New Issue
Block a user