会话相关

This commit is contained in:
2026-08-14 19:57:46 +08:00
parent 362cb4332f
commit 315d05c3ea
8 changed files with 660 additions and 134 deletions
-13
View File
@@ -1,13 +0,0 @@
import request from '/@/utils/request';
/**
* 查询会话问答消息记录(普通对话历史)。
* 依赖后端接口,URL/返回结构待后端提供;接口未就绪前由调用方以 localStorage 兜底。
*/
export function getSessionMessages(sessionId: string) {
return request({
url: '/ai-agent/session/getMessages',
method: 'get',
params: { sessionId },
});
}
+54
View File
@@ -0,0 +1,54 @@
import request, { type RequestOptions } from '/@/utils/request';
// ===== 会话管理(/ai-agent/session/*=====
// 后端结构:
// ListSessionReq { page } → ListSessionRes { list: VOSession[], total }
// GetSessionInfoReq { page, sessionId } → GetSessionInfoRes { list: VOSessionInfoResult[], total }
// DeleteSessionReq { id }POST,级联删除普通对话结果,工作流结果保留)
// 说明:会话为「会话 → 会话内结果(工作流/普通对话混排,按时间倒序)」模型。
export interface VOSession {
id: string;
sessionName: string;
createdAt: string;
}
export interface VOSessionInfoResult {
id: string;
type: 'workflow' | 'chat';
status: number; // 1-运行中, 2-成功, 3-失败
flowId: string;
requestParams: Record<string, any>;
resultFileUrl: string;
totalTokens: number;
totalFee: number;
errorMsg: string;
createdAt: string;
}
export function getSessionListV2(params?: Record<string, any>, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/list',
method: 'get',
params,
requestOptions,
}) as Promise<{ code: number; message: string; data: { list: VOSession[]; total: number } }>;
}
export function getSessionResults(sessionId: string, params?: Record<string, any>, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/get',
method: 'get',
params: { sessionId, ...params },
requestOptions,
}) as Promise<{ code: number; message: string; data: { list: VOSessionInfoResult[]; total: number } }>;
}
export function deleteSessionV2(id: string, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/delete',
method: 'post',
data: { id },
requestOptions,
});
}
+5 -1
View File
@@ -40,6 +40,9 @@
</div>
<!-- AI 消息操作栏hover 显示 -->
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
<button class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
<el-icon><RefreshRight /></el-icon>
</button>
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
<el-icon><DocumentCopy /></el-icon>
</button>
@@ -53,7 +56,7 @@
<script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { DocumentCopy } from '@element-plus/icons-vue';
import { DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
import { renderMarkdown } from '../utils/markdown';
@@ -70,6 +73,7 @@ interface ChatMessage {
interface Emits {
(e: 'retry', msg: ChatMessage): void;
(e: 'regenerate', msg: ChatMessage): void;
}
interface Props {
+22 -3
View File
@@ -41,8 +41,14 @@
<div class="toolbar-right">
<span class="hint-text">Shift+Enter 换行</span>
<button class="send-btn" :disabled="sendDisabled" @click="handleSend">
<el-icon><Top /></el-icon>
<button
class="send-btn"
:class="{ 'is-stop': generating }"
:disabled="!generating && sendDisabled"
@click="generating ? emit('stop') : handleSend()"
>
<el-icon v-if="generating"><VideoPause /></el-icon>
<el-icon v-else><Top /></el-icon>
</button>
</div>
</div>
@@ -69,7 +75,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Top, MagicStick, Promotion, Close, Paperclip } from '@element-plus/icons-vue';
import { Top, MagicStick, Promotion, Close, Paperclip, VideoPause } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { getWorkflowList } from '/@/api/settings/creation';
@@ -77,11 +83,13 @@ interface Props {
sendDisabled?: boolean;
workflowLocked?: boolean;
hideShortcuts?: boolean;
generating?: boolean;
}
interface Emits {
(e: 'send', message: string): void;
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
(e: 'stop'): void;
}
interface Workflow {
@@ -96,6 +104,7 @@ const props = withDefaults(defineProps<Props>(), {
sendDisabled: false,
workflowLocked: false,
hideShortcuts: false,
generating: false,
});
const emit = defineEmits<Emits>();
const message = ref('');
@@ -384,6 +393,16 @@ const onCapsuleAfterLeave = (el: Element) => {
cursor: not-allowed;
transform: none;
}
/* 生成中:变停止按钮 */
&.is-stop {
background: #f43f5e;
color: #fff;
&:hover {
background: #e11d48;
}
}
}
/* 快捷工作流胶囊 */
+42 -3
View File
@@ -130,9 +130,21 @@
</div>
</div>
<!-- 对话页 — 无工作流但有消息时展示消息流 -->
<div v-else-if="hasMessages" :key="'chat'" class="content-body chat-body">
<ChatList :messages="messages" @retry="emit('retry', $event)" />
<!-- 对话页 — 无工作流但有消息或会话结果时展示:结果卡片(仅工作流结果时显示)+ 消息流(下) -->
<div v-else-if="hasMessages || hasResults" :key="'chat'" class="content-body chat-body">
<SessionResults
v-if="hasWorkflowResults"
:results="results"
:loading="resultsLoading"
@preview="emit('preview-result', $event)"
@download="emit('download-result', $event)"
/>
<ChatList
:messages="messages"
class="chat-list-scroll"
@retry="emit('retry', $event)"
@regenerate="emit('regenerate', $event)"
/>
</div>
<!-- 默认占位 — 无工作流时显示引导 -->
@@ -170,26 +182,37 @@ import { collectHomeFormFields } from '../utils/flowDsl';
import { getWorkflowList } from '/@/api/settings/creation';
import { Promotion } from '@element-plus/icons-vue';
import ChatList from './ChatList.vue';
import SessionResults from './SessionResults.vue';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
activeMenu: string;
workflowDetail: any;
activeHistoryId?: string | null;
messages?: any[];
results?: VOSessionInfoResult[];
resultsLoading?: 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;
}
const props = withDefaults(defineProps<Props>(), {
activeHistoryId: null,
messages: () => [],
results: () => [],
resultsLoading: false,
});
const emit = defineEmits<Emits>();
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
const hasWorkflowResults = computed(() => Array.isArray(props.results) && props.results.some((r) => r.type === 'workflow'));
const formValues = reactive<Record<string, any>>({});
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
const uploadingFields = reactive<Record<string, boolean>>({});
@@ -593,11 +616,27 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
flex-direction: column;
padding: 0;
box-sizing: border-box;
overflow: hidden; /* 结果列表固定高度、消息流内部滚动,避免整体滚动与内部冲突 */
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
/* 会话结果区块:固定在消息流上方,结果多时内部滚动(不挤压消息流) */
.chat-body :deep(.session-results) {
flex-shrink: 0;
max-height: 42%;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
/* 消息流填充剩余高度并在内部滚动 */
.chat-body :deep(.chat-list-scroll) {
flex: 1;
min-height: 0;
height: auto;
}
.placeholder-body { display: flex; align-items: center; justify-content: center; }
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 740px; }
@@ -0,0 +1,317 @@
<template>
<div class="session-results">
<div class="results-header">
<span class="results-title">会话结果</span>
<span v-if="results.length" class="results-count">{{ results.length }}</span>
</div>
<!-- 加载中 -->
<div v-if="loading && results.length === 0" class="results-empty">加载中</div>
<!-- 空态 -->
<div v-else-if="results.length === 0" class="results-empty">暂无会话结果</div>
<!-- 结果列表 -->
<div v-else class="results-list">
<div v-for="r in results" :key="r.id" class="result-card" :class="`is-${statusKey(r.status)}`">
<div class="result-main">
<span class="result-type">{{ r.type === 'workflow' ? '工作流' : '对话' }}</span>
<span class="result-status" :class="`is-${statusKey(r.status)}`">
<i v-if="r.status === 1" class="status-dot"></i>{{ statusText(r.status) }}
</span>
<span v-if="r.totalTokens" class="result-meta">{{ r.totalTokens }} tokens</span>
<span v-if="r.totalFee" class="result-meta">¥{{ r.totalFee }}</span>
<span class="result-time">{{ r.createdAt || '' }}</span>
</div>
<!-- 问题 / 参数摘要 -->
<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>
</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>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue';
import { getUpFileUrl } from '/@/utils/gfast';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
results: VOSessionInfoResult[];
loading?: boolean;
}
interface Emits {
(e: 'preview', url: string): void;
(e: 'download', url: string): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
const emit = defineEmits<Emits>();
// 状态键:1-运行中, 2-成功, 3-失败
const statusKey = (status: number): string => {
if (status === 2) return 'success';
if (status === 3) return 'failed';
return 'running';
};
const statusText = (status: number): string => {
if (status === 2) return '成功';
if (status === 3) return '失败';
return '运行中';
};
// 卡片问题/参数摘要:chat 取 requestParams.question,其余输出参数 JSON 摘要
const getQuestionText = (r: VOSessionInfoResult): string => {
const q = r.requestParams?.question;
if (q) return String(q);
const params = r.requestParams;
if (params && Object.keys(params).length > 0) {
try {
return JSON.stringify(params);
} catch {
return '';
}
}
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">
.session-results {
width: 100%;
max-width: 880px;
margin: 0 auto;
padding: 12px 0 4px;
flex-shrink: 0;
}
.results-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.results-title {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.4px;
}
.results-count {
font-size: 11px;
color: #cbd5e1;
}
.results-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.results-empty {
font-size: 12px;
color: #cbd5e1;
padding: 8px 0;
}
.result-card {
background: #fff;
border: 1px solid #eef0f3;
border-radius: 10px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 4px;
transition: border-color 0.15s, box-shadow 0.15s;
&.is-running {
border-color: #dbeafe;
background: #f8fbff;
}
&.is-failed {
border-color: #fee2e2;
background: #fef6f6;
}
}
.result-main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.result-type {
font-size: 11px;
font-weight: 600;
color: #64748b;
background: #f1f5f9;
border-radius: 4px;
padding: 1px 6px;
}
.result-status {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
&.is-running {
color: #2563eb;
}
&.is-success {
color: #16a34a;
}
&.is-failed {
color: #dc2626;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2563eb;
animation: status-pulse 1.2s ease-in-out infinite;
}
}
@keyframes status-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.result-meta {
font-size: 11px;
color: #94a3b8;
}
.result-time {
font-size: 11px;
color: #cbd5e1;
margin-left: auto;
}
.result-sub {
display: flex;
align-items: center;
gap: 10px;
}
.result-error {
font-size: 12px;
color: #dc2626;
flex: 1;
word-break: break-all;
}
.result-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.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;
color: #1e293b;
padding: 2px 0;
word-break: break-all;
}
/* AI 回复区(txt 内容) */
.result-reply {
margin-top: 2px;
}
.result-reply-text {
font-size: 13px;
color: #334155;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 8px 10px;
&::-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>
+3 -3
View File
@@ -6,7 +6,7 @@
<el-icon class="brand-icon"><ChatDotRound /></el-icon>
<span class="brand-name">AI 助手</span>
</div>
<el-tooltip content="新建话" placement="right">
<el-tooltip content="新建话" placement="right">
<button class="new-chat-icon-btn" @click="handleNewChat">
<el-icon><EditPen /></el-icon>
</button>
@@ -17,7 +17,7 @@
<div class="sidebar-tabs">
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
<el-icon><ChatLineRound /></el-icon>
话记录
话记录
</button>
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="activeTab = 'workspace'">
<el-icon><FolderOpened /></el-icon>
@@ -47,7 +47,7 @@
</div>
</div>
<div v-else class="panel-empty">暂无话记录</div>
<div v-else class="panel-empty">暂无话记录</div>
</div>
<!-- 工作空间面板 -->
+217 -111
View File
@@ -21,10 +21,24 @@
:workflow-detail="selectedWorkflowDetail"
:active-history-id="activeHistoryId"
:messages="currentMessages"
:results="currentSessionResults"
:results-loading="currentSessionResultsLoading"
@retry="handleRetry"
@workflow-select="handlePlaceWorkflowSelect"
@regenerate="handleRetry"
@preview-result="handlePreviewResult"
@download-result="handleDownloadResult"
@workflow-select="handlePlaceWorkflowSelect"
/>
<InputBar
ref="inputBarRef"
:send-disabled="isSendDisabled"
:workflow-locked="isHistoryWorkflow"
:hide-shortcuts="isPlaceholder"
:generating="isGenerating"
@send="handleSend"
@workflow-select="handleWorkflowSelect"
@stop="handleStopGenerate"
/>
<InputBar ref="inputBarRef" :send-disabled="isSendDisabled" :workflow-locked="isHistoryWorkflow" :hide-shortcuts="isPlaceholder" @send="handleSend" @workflow-select="handleWorkflowSelect" />
</div>
<!-- 预览弹窗 -->
@@ -63,17 +77,17 @@ import { applyHomeFormValues } from './utils/flowDsl';
import { getChatModel } from '/@/api/settings/modelConfigV2';
import { openWsExecute } from './utils/wsExecute';
import { parseWsMessage, getDelta, getAnswer, getErrorText, getToolCallName, getToolResultText } from './utils/wsMessage';
import { getSessionMessages } from '/@/api/settings/session';
import type { ExecutionTreeItem } from '/@/api/settings/creation';
import {
getExecutionList,
getWorkflowDetail,
getExecutionDetail,
deleteExecutionResult,
deleteExecutionSession,
getSessionList,
downloadToFile,
} from '/@/api/settings/creation';
import { getSessionListV2, getSessionResults, deleteSessionV2 } from '/@/api/settings/workflow/session';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
import { getUpFileUrl } from '/@/utils/gfast';
interface HistoryItem {
id: string;
@@ -161,26 +175,46 @@ const previewNode = (data: TreeNode) => {
previewDialogVisible.value = true;
};
const downloadNode = async (data: TreeNode) => {
if (!data.fileUrl) return;
const url = buildAssetUrl(data.fileUrl);
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 {
// 优先通过后端代理下载,避免跨域问题
const res = await downloadToFile({ fileURL: data.fileUrl });
const res = await downloadToFile({ fileURL: url });
const blob = res.data as Blob;
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = data.label;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch {
// 兜底:直接用 a 标签下载
const full = buildAssetUrl(url);
const a = document.createElement('a');
a.href = url;
a.download = data.label;
a.href = full;
a.download = name;
a.target = '_blank';
a.rel = 'noopener';
document.body.appendChild(a);
@@ -189,6 +223,11 @@ const downloadNode = async (data: TreeNode) => {
}
};
const downloadNode = async (data: TreeNode) => {
if (!data.fileUrl) return;
await downloadAsset(data.fileUrl, data.label);
};
const handleDeleteNode = async (data: TreeNode) => {
try {
await ElMessageBox.confirm(`确定删除「${data.label}」?此操作不可恢复。`, '确认删除', {
@@ -238,30 +277,33 @@ const getSessionData = (res: any): any[] => {
const getList = async () => {
treeLoading.value = true;
try {
const [execRes, sessionRes] = await Promise.all([getExecutionList(), getSessionList({ pageSize: 10 })]);
const [execRes, sessionRes] = await Promise.all([getExecutionList(), getSessionListV2({ pageNum: 1, pageSize: 10 })]);
imgAddressPrefix.value = execRes.data?.imgAddressPrefix || '';
treeNodes.value = buildTreeNodes(execRes.data?.tree || []);
const d = new Date();
const todayStr = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const sessions = getSessionData(sessionRes).map((s) => {
const rawTime = s.createdAt || s.createDate || '';
let displayTime = rawTime;
if (rawTime) {
const datePart = rawTime.substring(0, 10);
if (datePart === todayStr) {
displayTime = rawTime.substring(11, 16);
} else {
displayTime = datePart;
const sessions = getSessionData(sessionRes)
// 后端个别会话 sessionId 可能为空串,空 id 无法点击查询详情,过滤掉
.filter((s) => s.sessionId)
.map((s) => {
const rawTime = s.createdAt || '';
let displayTime = rawTime;
if (rawTime) {
const datePart = rawTime.substring(0, 10);
if (datePart === todayStr) {
displayTime = rawTime.substring(11, 16);
} else {
displayTime = datePart;
}
}
}
return {
id: s.id,
sessionId: s.sessionId,
title: s.flowName || '未命名会话',
time: displayTime,
status: mapSessionStatus(s.status),
};
});
return {
id: String(s.sessionId),
sessionId: String(s.sessionId),
title: s.sessionName || '未命名会话',
time: displayTime,
status: mapSessionStatus(s.status),
};
});
historyList.value = sessions;
// 为每个后端会话初始化消息存储
sessions.forEach((s) => {
@@ -281,7 +323,13 @@ const getList = async () => {
const selectedWorkflowDetail = ref<any>(null);
const mainContentRef = ref<any>(null);
const sendingSessions = reactive<Record<string, boolean>>({});
// 当前正在执行的 WebSocket(用于停止生成);cancelledWs 记录被主动取消的连接,避免其 onClose 误判为正常完成
const currentWs = ref<WebSocket | null>(null);
const cancelledWs = new Set<WebSocket>();
const isHistoryWorkflow = ref(false);
// 生成中:有活跃会话且该会话正处于发送状态(发送按钮切换为停止按钮)
const isGenerating = computed(() => !!activeHistoryId.value && !!sendingSessions[activeHistoryId.value]);
const inputBarRef = ref<any>(null);
const templateDialogVisible = ref(false);
const pendingTemplate = ref<any>(null);
@@ -300,6 +348,18 @@ const currentMessages = computed(() => {
return sessionMessages.value.get(id) || [];
});
// 会话内结果(session/getworkflow+chat 混排):key = 会话 id
const sessionResultsMap = reactive<Record<string, VOSessionInfoResult[]>>({});
const sessionResultsLoading = reactive<Record<string, boolean>>({});
const currentSessionResults = computed(() => {
const id = activeHistoryId.value;
return id ? sessionResultsMap[id] || [] : [];
});
const currentSessionResultsLoading = computed(() => {
const id = activeHistoryId.value;
return !!id && !!sessionResultsLoading[id];
});
// 占位态:无工作流且无消息(MainContent 显示引导页)时隐藏输入框快捷胶囊,避免与占位卡片重复
const isPlaceholder = computed(() => !selectedWorkflowDetail.value && currentMessages.value.length === 0);
@@ -355,39 +415,12 @@ const handleMenuChange = (menu: string) => {
const formatTime = (d: Date) => String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
// ===== 消息缓存(普通对话历史;后端接口就绪前 localStorage 兜底)=====
const CHAT_CACHE_KEY = 'home_chat_msgs_';
const chatCacheKey = (sid: string) => CHAT_CACHE_KEY + sid;
const loadChatCache = (sid: string): ChatMessage[] => {
try {
const raw = localStorage.getItem(chatCacheKey(sid));
return raw ? (JSON.parse(raw) as ChatMessage[]) : [];
} catch {
return [];
}
};
const saveChatCache = (sid: string, msgs: ChatMessage[]) => {
try {
localStorage.setItem(chatCacheKey(sid), JSON.stringify(msgs));
} catch {
/* 存储不可用时忽略 */
}
};
const clearChatCache = (sid: string) => {
try {
localStorage.removeItem(chatCacheKey(sid));
} catch {
/* ignore */
}
};
const addMessage = (msg: ChatMessage) => {
const id = activeHistoryId.value;
if (!id) return;
const list = sessionMessages.value.get(id);
if (list) list.push(msg);
else sessionMessages.value.set(id, [msg]);
saveChatCache(id, sessionMessages.value.get(id) || []);
};
const handleSend = async (message: string) => {
@@ -473,7 +506,6 @@ const handleRetry = async (msg: ChatMessage) => {
if (!question) return;
// 移除失败消息,重新执行
list.splice(idx, 1);
saveChatCache(sid, list);
sendingSessions[sid] = true;
session.status = 'executing';
const sessionId = session.sessionId || getSessionId();
@@ -486,6 +518,42 @@ const handleRetry = async (msg: ChatMessage) => {
}
};
// ===== 停止生成:发送 workflow_cancel 并关闭连接,保留已生成内容 =====
const handleStopGenerate = () => {
const sid = activeHistoryId.value;
if (!sid || !sendingSessions[sid]) return;
const ws = currentWs.value;
if (ws && ws.readyState === WebSocket.OPEN) {
cancelledWs.add(ws);
try {
ws.send(JSON.stringify({ type: 'workflow_cancel' }));
} catch {
/* 连接异常时忽略,直接关闭 */
}
ws.close();
}
currentWs.value = null;
// 标记正在生成的 AI 消息:已有内容则保留;无内容则显示停止提示
const list = sessionMessages.value.get(sid);
const last = list && list.length > 0 ? list[list.length - 1] : null;
if (last && !last.isUser) {
last.loading = false;
if (!last.content) last.content = '⚠️ 已停止生成';
} else if (list) {
// 工作流执行模式:无 AI 气泡,追加一条停止提示
addMessage({
id: 'msg-' + Date.now() + '-stop',
content: '⚠️ 已停止生成',
time: formatTime(new Date()),
isUser: false,
});
}
// 清理会话状态(runChat 的 onClose 会再走一次 finishChat,幂等)
const session = historyList.value.find((h) => h.id === sid);
if (session && session.status === 'executing') session.status = 'completed';
delete sendingSessions[sid];
};
// ===== 工作流执行:选中工作流 → 表单页 WS 执行 → 完成后切回对话页 =====
const runWorkflow = async (sid: string, sessionId: string, message: string, mc: any) => {
const curSession = historyList.value.find((h) => h.id === sid);
@@ -518,13 +586,15 @@ const runWorkflow = async (sid: string, sessionId: string, message: string, mc:
}
// 虚拟会话执行后刷新会话列表替换真实条目
if (curSession.id.startsWith('virtual_')) {
getSessionList().then((sessionRes) => {
const freshList = getSessionData(sessionRes).map((s: any) => ({
id: s.id,
sessionId: s.sessionId,
title: s.flowName || '未命名会话',
time: s.createDate?.substring(0, 10) || '',
}));
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);
@@ -537,8 +607,12 @@ const runWorkflow = async (sid: string, sessionId: string, message: string, mc:
activeHistoryId.value = match.id;
}
}
loadSessionResults(match.id);
}
});
} else {
// 非虚拟会话:执行完成/失败后直接刷新会话结果
loadSessionResults(sid);
}
delete sendingSessions[sid];
// 执行完成后切回对话页:清空工作流选择,主区域展示消息流
@@ -597,10 +671,14 @@ const runWorkflow = async (sid: string, sessionId: string, message: string, mc:
finishExec(false, '执行连接失败,请重试');
},
onClose: () => {
// 主动停止(handleStopGenerate 已发 workflow_cancel 并 close)时跳过,
// 避免被误判为正常完成(工作流完成/失败提示由停止逻辑单独处理)
if (ws && cancelledWs.has(ws)) return;
// 连接关闭兜底视为完成;若已触发 onError 则按失败处理
if (!finished) finishExec(!wsFailed);
},
});
currentWs.value = ws;
if (!ws) {
finishExec(false, 'WebSocket 初始化失败,请检查服务地址');
}
@@ -634,7 +712,6 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
const ai = aiIdx >= 0 && list ? list[aiIdx] : null;
if (ai && ai.thinking === '思考中…') {
ai.thinking = '';
saveChatCache(sid, list || []);
}
twBuffer += text;
startTypewriter();
@@ -658,7 +735,6 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
ai.thinking = (ai.thinking || '') + twBuffer.slice(0, take);
twBuffer = twBuffer.slice(take);
ai.loading = false;
saveChatCache(sid, list || []);
}, 100);
};
@@ -677,7 +753,6 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
const ai = aiIdx >= 0 && list ? list[aiIdx] : null;
if (ai) {
ai.thinking = (ai.thinking || '') + twBuffer;
saveChatCache(sid, list || []);
}
twBuffer = '';
};
@@ -710,13 +785,15 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
if (session) session.status = success ? 'completed' : 'failed';
// 虚拟会话对话后刷新会话列表替换真实条目
if (session && session.id.startsWith('virtual_')) {
getSessionList().then((sessionRes) => {
const freshList = getSessionData(sessionRes).map((s: any) => ({
id: s.id,
sessionId: s.sessionId,
title: s.flowName || '未命名会话',
time: s.createDate?.substring(0, 10) || '',
}));
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);
@@ -725,15 +802,17 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
sessionMessages.value.delete(session.id);
sessionMessages.value.set(match.id, msgs || []);
historyList.value[idx] = { ...match, status: session.status };
saveChatCache(match.id, msgs || []);
if (activeHistoryId.value === session.id) {
activeHistoryId.value = match.id;
}
}
loadSessionResults(match.id);
}
});
} else {
// 非虚拟会话:对话完成/失败后直接刷新会话结果
loadSessionResults(sid);
}
saveChatCache(sid, sessionMessages.value.get(sid) || []);
delete sendingSessions[sid];
};
@@ -801,7 +880,6 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
if (ai && delta) {
ai.loading = false;
ai.content = (ai.content || '') + delta;
saveChatCache(sid, list || []);
}
return;
}
@@ -812,7 +890,6 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
ai.loading = false;
ai.content = answer;
ai.thinkingSeconds = thinkStart ? Math.max(1, Math.round((Date.now() - thinkStart) / 1000)) : undefined;
saveChatCache(sid, list || []);
}
finishChat(true);
return;
@@ -829,10 +906,12 @@ const runChat = async (sid: string, sessionId: string, message: string) => {
finishChat(false, '对话连接失败,请重试');
},
onClose: () => {
// 连接关闭兜底完成
// 连接关闭兜底完成(主动停止时 handleStopGenerate 已把 AI 消息标记为「已停止生成」,
// 此处走成功分支:flush 打字机剩余思考、清理会话状态,且不覆盖已有内容)
if (!done) finishChat(!failed);
},
});
currentWs.value = ws;
if (!ws) {
finishChat(false, 'WebSocket 初始化失败,请检查服务地址');
}
@@ -842,12 +921,15 @@ const handleSelectHistory = async (id: string) => {
activeHistoryId.value = id;
activeMenu.value = 'chat';
const session = historyList.value.find((h) => h.id === id);
// 加载会话内结果列表(session/get),用它判断会话类型:普通会话(无 workflow 结果)只依赖这一个接口,不再调 execution/get
const results = await loadSessionResults(id);
const hasWorkflow = Array.isArray(results) && results.some((r) => r.type === 'workflow');
let asForm = false;
if (session && !session.id.startsWith('virtual_')) {
if (session && hasWorkflow) {
// 工作流会话:查 execution/get 回显表单(此处逻辑暂保留,后续再优化为用 session/get 数据)
try {
const res = await getExecutionDetail(session.id);
if (res.data) {
// 工作流会话:回显表单页(可改参数重跑),并锁定工作流选择
selectedWorkflowDetail.value = res.data;
isHistoryWorkflow.value = true;
asForm = true;
@@ -863,11 +945,11 @@ const handleSelectHistory = async (id: string) => {
}
}
if (!asForm) {
// 普通对话会话 / 虚拟会话:切回对话页,加载消息记录
// 普通对话会话 / 虚拟会话:切回对话页,把 session/get 结果转换为正常对话消息流展示
selectedWorkflowDetail.value = null;
isHistoryWorkflow.value = false;
inputBarRef.value?.clearWorkflow?.();
await loadSessionMessages(id);
await loadSessionMessagesFromResults(id, results);
}
// 清除可重新执行的标记
if (session?.status === 'completed' || session?.status === 'failed' || (session?.status === 'executing' && !sendingSessions[session.id])) {
@@ -875,35 +957,58 @@ const handleSelectHistory = async (id: string) => {
}
};
// 加载会话消息:优先后端接口,接口未就绪时读本地缓存兜底
const loadSessionMessages = async (sid: string) => {
const existing = sessionMessages.value.get(sid);
if (existing && existing.length > 0) return; // 内存已有消息直接保留
// 加载会话内结果(session/get):workflow+chat 混排,按时间倒序;返回结果列表供判断会话类型
const loadSessionResults = async (sid: string) => {
if (!sid) return [];
sessionResultsLoading[sid] = true;
try {
const res: any = await getSessionMessages(sid);
const list = Array.isArray(res?.data) ? res.data : res?.data?.list || res?.data?.messages;
if (Array.isArray(list) && list.length > 0) {
const msgs: ChatMessage[] = list.map((m: any, i: number) => ({
id: m.id || 'msg-' + Date.now() + '-' + i,
content: m.content ?? m.message ?? m.reply ?? '',
time: m.time || m.createDate || '',
isUser: m.isUser === true || m.role === 'user',
}));
sessionMessages.value.set(sid, msgs);
saveChatCache(sid, msgs);
return;
}
const res: any = await getSessionResults(sid);
const list = Array.isArray(res?.data?.list) ? res.data.list : Array.isArray(res?.data) ? res.data : [];
sessionResultsMap[sid] = list;
return list;
} catch {
// 接口不可用 → 兜底本地缓存
}
const cached = loadChatCache(sid);
if (cached.length > 0) {
sessionMessages.value.set(sid, cached);
} else if (!sessionMessages.value.has(sid)) {
sessionMessages.value.set(sid, []);
if (!sessionResultsMap[sid]) sessionResultsMap[sid] = [];
return sessionResultsMap[sid] || [];
} finally {
sessionResultsLoading[sid] = false;
}
};
// 普通对话会话回显:把 session/get 的 chat 结果转换为「问题(用户) + 回复(AI)」消息流,正常对话气泡展示
const loadSessionMessagesFromResults = async (sid: string, results: VOSessionInfoResult[]) => {
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 });
}
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 });
}
});
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 createNewSession = () => {
const sessionId = `virtual_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
historyList.value.unshift({
@@ -938,7 +1043,7 @@ const handleDeleteHistory = async (id: string) => {
const isVirtual = id.startsWith('virtual_');
if (!isVirtual) {
try {
await deleteExecutionSession({ id });
await deleteSessionV2(id);
} catch {
// 后端已提示错误,继续本地删除
}
@@ -947,7 +1052,8 @@ const handleDeleteHistory = async (id: string) => {
if (idx < 0) return;
historyList.value.splice(idx, 1);
sessionMessages.value.delete(id);
clearChatCache(id);
sessionResultsMap[id] = [];
delete sessionResultsLoading[id];
if (activeHistoryId.value === id) {
if (historyList.value.length > 0) {
activeHistoryId.value = historyList.value[0].id;