feat: 接入聊天历史界面与外链会话恢复
- 新增管理端与用户端聊天历史接口和页面 - 外链聊天支持访问令牌登录、身份保活与当前会话恢复 - 聊天执行链路切到统一 runtime 与 chatlog 查询接口
This commit is contained in:
646
easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue
Normal file
646
easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { useEasyFlowDrawer } from '@easyflow/common-ui';
|
||||
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElEmpty,
|
||||
ElInput,
|
||||
ElPagination,
|
||||
ElSelect,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
} from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import ChatHistoryDetailDrawer from '#/components/chat-history/ChatHistoryDetailDrawer.vue';
|
||||
import ListPageShell from '#/components/page/ListPageShell.vue';
|
||||
|
||||
const assistantList = ref<any[]>([]);
|
||||
const sessions = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const query = ref({
|
||||
assistantId: undefined as number | undefined,
|
||||
userAccount: '',
|
||||
timeRange: [] as string[],
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const quickRange = ref<'' | 'last7' | 'last30' | 'today'>('');
|
||||
const pageState = ref({
|
||||
total: 0,
|
||||
});
|
||||
const pageSizeOptions = [20, 50, 100];
|
||||
const quickRangeOptions = [
|
||||
{ label: '今天', value: 'today' as const },
|
||||
{ label: '最近 7 天', value: 'last7' as const },
|
||||
{ label: '最近 30 天', value: 'last30' as const },
|
||||
];
|
||||
|
||||
const drawerLoading = ref(false);
|
||||
const currentSession = ref<any>();
|
||||
const messageList = ref<any[]>([]);
|
||||
const messagePage = ref({
|
||||
total: 0,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useEasyFlowDrawer({
|
||||
appendToMain: false,
|
||||
class:
|
||||
'!w-[820px] max-w-[calc(100vw-24px)] !border-0 !bg-[hsl(var(--glass-tint))/0.28] shadow-[0_24px_48px_-36px_hsl(var(--foreground)/0.12)] supports-[backdrop-filter]:!bg-[hsl(var(--glass-tint))/0.2]',
|
||||
closable: false,
|
||||
contentClass: 'p-0',
|
||||
footer: false,
|
||||
header: false,
|
||||
modal: false,
|
||||
placement: 'right',
|
||||
});
|
||||
|
||||
const hasMoreMessages = computed(
|
||||
() => messageList.value.length < messagePage.value.total,
|
||||
);
|
||||
|
||||
const selectedSessionId = computed(() =>
|
||||
String(currentSession.value?.id || ''),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchAssistants(), fetchSessions()]);
|
||||
});
|
||||
|
||||
async function fetchAssistants() {
|
||||
const [, res] = await tryit(api.get)('/api/v1/bot/list', {
|
||||
params: { status: 1 },
|
||||
});
|
||||
if (res?.errorCode === 0) {
|
||||
assistantList.value = (res.data || []).map((item: any) => ({
|
||||
label: item.title,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSessions() {
|
||||
loading.value = true;
|
||||
const [, res] = await tryit(api.get)('/api/v1/chatHistory/sessions', {
|
||||
params: {
|
||||
assistantId: query.value.assistantId,
|
||||
userAccount: query.value.userAccount || undefined,
|
||||
startTime: query.value.timeRange?.[0],
|
||||
endTime: query.value.timeRange?.[1],
|
||||
pageNumber: query.value.pageNumber,
|
||||
pageSize: query.value.pageSize,
|
||||
},
|
||||
});
|
||||
loading.value = false;
|
||||
if (res?.errorCode === 0) {
|
||||
sessions.value = res.data?.records || [];
|
||||
pageState.value.total = res.data?.total || 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function openSession(sessionId: number | string) {
|
||||
drawerLoading.value = true;
|
||||
currentSession.value =
|
||||
sessions.value.find((item: any) => String(item.id) === String(sessionId)) ||
|
||||
undefined;
|
||||
messageList.value = [];
|
||||
messagePage.value = {
|
||||
total: 0,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
drawerApi.open();
|
||||
|
||||
const [, summaryRes] = await tryit(api.get)(
|
||||
`/api/v1/chatHistory/sessions/${sessionId}`,
|
||||
);
|
||||
if (summaryRes?.errorCode !== 0) {
|
||||
drawerLoading.value = false;
|
||||
drawerApi.close();
|
||||
return;
|
||||
}
|
||||
|
||||
currentSession.value = summaryRes.data;
|
||||
|
||||
await loadMessages(true);
|
||||
drawerLoading.value = false;
|
||||
}
|
||||
|
||||
async function loadMessages(reset = false) {
|
||||
if (!currentSession.value?.id) {
|
||||
return;
|
||||
}
|
||||
const nextPageNumber = reset ? 1 : messagePage.value.pageNumber + 1;
|
||||
const [, res] = await tryit(api.get)(
|
||||
`/api/v1/chatHistory/sessions/${currentSession.value.id}/messages`,
|
||||
{
|
||||
params: {
|
||||
pageNumber: nextPageNumber,
|
||||
pageSize: messagePage.value.pageSize,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (res?.errorCode !== 0) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeMessages(res.data?.records || []);
|
||||
messageList.value = reset
|
||||
? normalized
|
||||
: [...normalized, ...messageList.value];
|
||||
messagePage.value.total = res.data?.total || 0;
|
||||
messagePage.value.pageNumber = nextPageNumber;
|
||||
}
|
||||
|
||||
function normalizeMessages(records: any[]) {
|
||||
return [...records].reverse().map((item: any) => ({
|
||||
key: String(item.id),
|
||||
role: item.senderRole === 'assistant' ? 'assistant' : 'user',
|
||||
content:
|
||||
item.senderRole === 'assistant'
|
||||
? String(item.contentText || '').replace(/^Final Answer:\s*/i, '')
|
||||
: item.contentText,
|
||||
created: item.created,
|
||||
senderName: item.senderName,
|
||||
chains: Array.isArray(item.contentPayload?.chains)
|
||||
? item.contentPayload.chains.map((chain: any) =>
|
||||
chain?.id
|
||||
? chain
|
||||
: {
|
||||
...chain,
|
||||
thinkingExpanded: false,
|
||||
},
|
||||
)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
function changePage(pageNumber: number) {
|
||||
query.value.pageNumber = pageNumber;
|
||||
fetchSessions();
|
||||
}
|
||||
|
||||
function changePageSize(pageSize: number) {
|
||||
query.value.pageNumber = 1;
|
||||
query.value.pageSize = pageSize;
|
||||
fetchSessions();
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const time = new Date(value);
|
||||
if (Number.isNaN(time.getTime())) {
|
||||
return value;
|
||||
}
|
||||
const year = time.getFullYear();
|
||||
const month = String(time.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(time.getDate()).padStart(2, '0');
|
||||
const hour = String(time.getHours()).padStart(2, '0');
|
||||
const minute = String(time.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.value.pageNumber = 1;
|
||||
fetchSessions();
|
||||
}
|
||||
|
||||
function applyQuickRange(range: 'last7' | 'last30' | 'today') {
|
||||
quickRange.value = range;
|
||||
query.value.timeRange = resolveQuickRange(range);
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleTimeRangeChange(value?: string[]) {
|
||||
quickRange.value = '';
|
||||
query.value.timeRange = normalizeTimeRange(value);
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function resolveQuickRange(range: 'last7' | 'last30' | 'today') {
|
||||
const now = new Date();
|
||||
const end = endOfDay(now);
|
||||
let start = startOfDay(now);
|
||||
|
||||
if (range === 'last7') {
|
||||
start = startOfDay(addDays(now, -6));
|
||||
} else if (range === 'last30') {
|
||||
start = startOfDay(addDays(now, -29));
|
||||
}
|
||||
|
||||
return [formatDateTime(start), formatDateTime(end)];
|
||||
}
|
||||
|
||||
function normalizeTimeRange(value?: string[]) {
|
||||
if (!value?.length) {
|
||||
return [];
|
||||
}
|
||||
const [startValue, endValue] = value;
|
||||
if (!startValue || !endValue) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
formatDateTime(startOfDay(new Date(startValue))),
|
||||
formatDateTime(endOfDay(new Date(endValue))),
|
||||
];
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
const value = new Date(date);
|
||||
value.setHours(0, 0, 0, 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
function endOfDay(date: Date) {
|
||||
const value = new Date(date);
|
||||
value.setHours(23, 59, 59, 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
const value = new Date(date);
|
||||
value.setDate(value.getDate() + days);
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDateTime(value: Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
const hour = String(value.getHours()).padStart(2, '0');
|
||||
const minute = String(value.getMinutes()).padStart(2, '0');
|
||||
const second = String(value.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
drawerApi.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-history-page flex h-full flex-col gap-6 p-6">
|
||||
<ListPageShell class="flex-1" :content-padding="20">
|
||||
<template #filters>
|
||||
<div class="chat-history-page__filters">
|
||||
<ElSelect
|
||||
v-model="query.assistantId"
|
||||
clearable
|
||||
placeholder="筛选聊天助手"
|
||||
:options="assistantList"
|
||||
class="chat-history-page__filter-control is-select"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<ElInput
|
||||
v-model="query.userAccount"
|
||||
class="chat-history-page__filter-control is-input"
|
||||
placeholder="搜索聊天用户"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<div class="chat-history-page__quick-ranges">
|
||||
<ElButton
|
||||
v-for="item in quickRangeOptions"
|
||||
:key="item.value"
|
||||
:type="quickRange === item.value ? 'primary' : 'default'"
|
||||
class="chat-history-page__quick-range-button"
|
||||
@click="applyQuickRange(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElDatePicker
|
||||
v-model="query.timeRange"
|
||||
class="chat-history-page__filter-control is-range"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
@change="handleTimeRangeChange"
|
||||
/>
|
||||
<ElButton type="primary" @click="handleSearch">查询</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="loading" class="chat-history-page__content">
|
||||
<div v-if="sessions.length > 0" class="chat-history-page__table-shell">
|
||||
<ElTable
|
||||
:data="sessions"
|
||||
:current-row-key="selectedSessionId || undefined"
|
||||
row-key="id"
|
||||
class="chat-history-page__table"
|
||||
height="100%"
|
||||
highlight-current-row
|
||||
empty-text=""
|
||||
@row-click="(row) => openSession(row.id)"
|
||||
>
|
||||
<ElTableColumn label="会话信息" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="chat-history-page__session-cell">
|
||||
<div class="chat-history-page__session-title">
|
||||
{{ row.title || '未命名会话' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="row.lastMessagePreview"
|
||||
class="chat-history-page__session-preview"
|
||||
>
|
||||
{{ row.lastMessagePreview }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="聊天助手" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="chat-history-page__assistant-chip">
|
||||
{{ row.assistantName || '聊天助手' }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn prop="userAccount" label="聊天用户" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="chat-history-page__user-cell">
|
||||
{{ row.userAccount || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="消息数" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="chat-history-page__count-pill">
|
||||
{{ row.messageCount || 0 }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn label="最近活跃" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="chat-history-page__time-cell">
|
||||
{{ formatTime(row.lastMessageAt || row.accessAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn width="108" align="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
class="chat-history-page__detail-action"
|
||||
@click.stop="openSession(row.id)"
|
||||
>
|
||||
查看详情
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div v-else class="chat-history-page__empty">
|
||||
<ElEmpty description="暂无聊天历史" />
|
||||
</div>
|
||||
|
||||
<div v-if="pageState.total > 0" class="chat-history-page__pagination">
|
||||
<ElPagination
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:current-page="query.pageNumber"
|
||||
:page-size="query.pageSize"
|
||||
:page-sizes="pageSizeOptions"
|
||||
:total="pageState.total"
|
||||
@current-change="changePage"
|
||||
@size-change="changePageSize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ListPageShell>
|
||||
|
||||
<Drawer>
|
||||
<ChatHistoryDetailDrawer
|
||||
:visible="true"
|
||||
:loading="drawerLoading"
|
||||
:session="currentSession"
|
||||
:messages="messageList"
|
||||
:has-more="hasMoreMessages"
|
||||
:on-load-more="() => loadMessages(false)"
|
||||
@close="closeDetail"
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-history-page__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-history-page__filter-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-history-page__filter-control.is-select,
|
||||
.chat-history-page__filter-control.is-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.chat-history-page__filter-control.is-range {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.chat-history-page__quick-ranges {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-history-page__quick-range-button {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.chat-history-page__content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-history-page__table-shell {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--glass-border) / 0.42);
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--glass-border) / 0.28) 0%,
|
||||
hsl(var(--glass-tint) / 0.4) 14%,
|
||||
hsl(var(--surface-panel) / 0.94) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(var(--glass-border) / 0.54),
|
||||
0 24px 42px -36px hsl(var(--foreground) / 0.16);
|
||||
}
|
||||
|
||||
.chat-history-page__table {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-history-page__session-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.chat-history-page__session-title {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-history-page__session-preview {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--text-muted));
|
||||
word-break: break-word;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.chat-history-page__assistant-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid hsl(var(--glass-border) / 0.48);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--glass-tint) / 0.76);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-history-page__user-cell,
|
||||
.chat-history-page__time-cell {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--text-secondary));
|
||||
}
|
||||
|
||||
.chat-history-page__count-pill {
|
||||
display: inline-flex;
|
||||
min-width: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--surface-contrast-soft) / 0.88);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.chat-history-page__detail-action {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.chat-history-page__empty {
|
||||
display: flex;
|
||||
min-height: 360px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-history-page__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table),
|
||||
.chat-history-page__content :deep(.el-table__inner-wrapper),
|
||||
.chat-history-page__content :deep(.el-table__body-wrapper),
|
||||
.chat-history-page__content :deep(.el-scrollbar),
|
||||
.chat-history-page__content :deep(.el-scrollbar__wrap),
|
||||
.chat-history-page__content :deep(.el-scrollbar__view) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table tr) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table th.el-table__cell) {
|
||||
height: 48px;
|
||||
background: hsl(var(--surface-contrast-soft) / 0.54);
|
||||
border-bottom-color: hsl(var(--divider-faint) / 0.28);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.chat-history-page__content :deep(.el-table td.el-table__cell) {
|
||||
padding: 14px 0;
|
||||
background: transparent;
|
||||
border-bottom-color: hsl(var(--divider-faint) / 0.22);
|
||||
}
|
||||
|
||||
.chat-history-page__content
|
||||
:deep(.el-table__body tr:hover > td.el-table__cell) {
|
||||
background: hsl(var(--primary) / 0.04);
|
||||
}
|
||||
|
||||
.chat-history-page__content
|
||||
:deep(.el-table__body tr.current-row > td.el-table__cell) {
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.chat-history-page__filter-control.is-range {
|
||||
width: min(100%, 360px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-history-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-history-page__filters {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-history-page__filter-control.is-select,
|
||||
.chat-history-page__filter-control.is-input,
|
||||
.chat-history-page__filter-control.is-range {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user