feat: 接入聊天历史界面与外链会话恢复

- 新增管理端与用户端聊天历史接口和页面

- 外链聊天支持访问令牌登录、身份保活与当前会话恢复

- 聊天执行链路切到统一 runtime 与 chatlog 查询接口
This commit is contained in:
2026-04-05 11:37:25 +08:00
parent 25e80433a5
commit a4f75a5e4c
48 changed files with 3724 additions and 972 deletions

View File

@@ -1,5 +1,5 @@
import { createApp, watchEffect } from 'vue';
import { BubbleList, Sender, Thinking, XMarkdown } from 'vue-element-plus-x';
import { BubbleList, Sender, XMarkdown } from 'vue-element-plus-x';
import { registerAccessDirective } from '@easyflow/access';
import { registerLoadingDirective } from '@easyflow/common-ui';
@@ -41,7 +41,6 @@ async function bootstrap(namespace: string) {
app.component('ElBubbleList', BubbleList);
app.component('ElSender', Sender);
app.component('ElXMarkdown', XMarkdown);
app.component('ElThinking', Thinking);
// 注册EasyFlow提供的v-loading和v-spinning指令
registerLoadingDirective(app, {

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ChatThinkingBlock } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import { useUserStore } from '@easyflow/stores';
@@ -48,13 +49,14 @@ function getUserAvatar() {
v-for="(chain, index) in item.chains"
:key="chain.id || index"
>
<ElThinking
<ChatThinkingBlock
v-if="!('id' in chain)"
v-model="chain.thinlCollapse"
v-model:expanded="chain.thinkingExpanded"
:content="chain.reasoning_content"
:status="chain.thinkingStatus"
class="chat-thinking-block-item"
/>
<ElCollapse v-else class="mb-2">
<ElCollapse v-else class="chat-tool-panel">
<ElCollapseItem :title="chain.name" :name="chain.id">
<template #title>
<div class="flex items-center gap-2 pl-5">
@@ -90,41 +92,6 @@ function getUserAvatar() {
</template>
</template>
<!-- <ElThinking
v-if="item.reasoning_content"
v-model="item.thinlCollapse"
:content="item.reasoning_content"
:status="item.thinkingStatus"
/> -->
<!-- <ElCollapse v-if="item.tools" class="mb-2">
<ElCollapseItem
class="mb-2"
v-for="tool in item.tools"
:key="tool.id"
:title="tool.name"
:name="tool.id"
>
<template #title>
<div class="flex items-center gap-2 pl-5">
<ElIcon size="16">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span>{{ tool.name }}</span>
<template v-if="tool.status === 'TOOL_CALL'">
<ElIcon size="16">
<IconifyIcon icon="svg:spinner" />
</ElIcon>
</template>
<template v-else>
<ElIcon size="16" color="var(--el-color-success)">
<CircleCheck />
</ElIcon>
</template>
</div>
</template>
<ShowJson :value="tool.result" />
</ElCollapseItem>
</ElCollapse> -->
</div>
</template>
@@ -162,23 +129,30 @@ function getUserAvatar() {
--bubble-content-max-width: 100%;
}
:deep(.el-thinking) {
margin: 0;
}
:deep(.el-thinking .content-wrapper) {
--el-thinking-content-wrapper-width: 100%;
.chat-thinking-block-item {
margin-bottom: 8px;
}
:deep(.el-collapse) {
:deep(.chat-tool-panel.el-collapse) {
overflow: hidden;
border: 1px solid var(--el-collapse-border-color);
border-radius: 8px;
border: 1px solid hsl(var(--divider-faint) / 0.26);
border-radius: 14px;
background: hsl(var(--surface-panel) / 0.7);
box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 0.2);
}
:deep(.el-collapse-item__content) {
:deep(.chat-tool-panel .el-collapse-item__wrap) {
background: transparent;
}
:deep(.chat-tool-panel .el-collapse-item__header) {
min-height: 44px;
padding-right: 14px;
background: transparent;
border-bottom-color: hsl(var(--divider-faint) / 0.16);
}
:deep(.chat-tool-panel .el-collapse-item__content) {
padding-bottom: 0;
}
</style>

View File

@@ -49,14 +49,16 @@ defineExpose({
function getSessionList(resetSession = false) {
api
.get('/userCenter/botConversation/list', {
.get('/userCenter/chatHistory/sessions', {
params: {
botId: props.bot.id,
assistantId: props.bot.id,
pageNumber: 1,
pageSize: 100,
},
})
.then((res) => {
if (res.errorCode === 0) {
sessionList.value = res.data;
sessionList.value = res.data.records || [];
if (resetSession) {
currentSession.value = {};
}
@@ -92,15 +94,27 @@ function clickSession(session: any) {
}
function getMessageList() {
api
.get('/userCenter/botMessage/getMessages', {
params: {
botId: props.bot.id,
conversationId: currentSession.value.id,
},
.get(`/userCenter/chatHistory/sessions/${currentSession.value.id}/messages`, {
params: { pageNumber: 1, pageSize: 100 },
})
.then((res) => {
if (res.errorCode === 0) {
props.onMessageList?.(res.data);
const records = Array.isArray(res.data?.records) ? [...res.data.records] : [];
props.onMessageList?.(
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,
placement: item.senderRole === 'assistant' ? 'start' : 'end',
created: item.created,
chains: Array.isArray(item.contentPayload?.chains)
? item.contentPayload.chains
: undefined,
})),
);
}
});
}
@@ -125,12 +139,8 @@ const updateLoading = ref(false);
function updateTitle() {
updateLoading.value = true;
api
.get('/userCenter/botConversation/updateConversation', {
params: {
botId: props.bot.id,
conversationId: currentSession.value.id,
.post(`/userCenter/chatHistory/sessions/${currentSession.value.id}/rename`, {
title: currentSession.value.title,
},
})
.then((res) => {
updateLoading.value = false;
@@ -150,12 +160,7 @@ function remove(row: any) {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.get('/userCenter/botConversation/deleteConversation', {
params: {
botId: props.bot.id,
conversationId: row.id,
},
})
.post(`/userCenter/chatHistory/sessions/${row.id}/delete`)
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
@@ -175,6 +180,11 @@ function remove(row: any) {
},
}).catch(() => {});
}
function openRenameDialog(row: any) {
currentSession.value = { ...row };
dialogVisible.value = true;
}
</script>
<template>
@@ -230,7 +240,7 @@ function remove(row: any) {
)
"
>
{{ formatCreatedTime(conversation.created) }}
{{ formatCreatedTime(conversation.lastMessageAt || conversation.accessAt || conversation.created) }}
</span>
<ElDropdown
:class="
@@ -249,7 +259,7 @@ function remove(row: any) {
@mouseenter="handleMouseEvent(conversation.id)"
@mouseleave="handleMouseEvent()"
>
<ElDropdownItem @click="dialogVisible = true">
<ElDropdownItem @click="openRenameDialog(conversation)">
<ElButton link :icon="Edit">编辑</ElButton>
</ElDropdownItem>
<ElDropdownItem>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { inject, ref } from 'vue';
@@ -16,8 +16,8 @@ import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
type Think = {
reasoning_content?: string;
thinkingStatus?: ThinkingStatus;
thinlCollapse?: boolean;
thinkingExpanded?: boolean;
thinkingStatus?: ChatThinkingBlockStatus;
};
type Tool = {
@@ -152,7 +152,7 @@ function sendMessage() {
if (index === -1) {
chains.push({
thinkingStatus: 'thinking',
thinlCollapse: true,
thinkingExpanded: false,
reasoning_content: delta,
});
} else {

View File

@@ -92,20 +92,6 @@ const coreRoutes: RouteRecordRaw[] = [
},
],
},
{
name: 'ChatHistoryShare',
path: '/share/:id',
component: () => import('#/views/chatHistory/share/index.vue'),
meta: {
title: '分享',
noBasicLayout: true,
hideInMenu: true,
hideInTab: true,
hideInBreadcrumb: true,
ignoreAccess: true,
loaded: true,
},
},
];
export { coreRoutes, fallbackNotFoundRoute };

View File

@@ -28,15 +28,20 @@ const routes: RouteRecordRaw[] = [
meta: {
icon: 'svg:chat-history',
order: 80,
title: '聊天记录',
title: '聊天历史',
},
},
{
name: 'ChatHistoryDetails',
path: '/chatHistory/:id',
component: () => import('#/views/chatHistory/details/index.vue'),
redirect: (to) => ({
path: '/chatHistory',
query: {
sessionId: String(to.params.id || ''),
},
}),
meta: {
title: '聊天记录',
title: '聊天历史',
hideInMenu: true,
hideInTab: true,
hideInBreadcrumb: true,

View File

@@ -1,168 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { IconifyIcon } from '@easyflow/icons';
import { copyToClipboard } from '@easyflow/utils';
import { ArrowLeft, Delete, MoreFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElContainer,
ElDropdown,
ElDropdownItem,
ElHeader,
ElMain,
ElMessage,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import { ChatBubbleList } from '#/components/chat';
import { router } from '#/router';
const route = useRoute();
const ids = reactive({
botId: '',
conversationId: '',
});
const conversationInfo = ref<any>();
const messageList = ref<any[]>([]);
const loading = ref(true);
onMounted(() => {
if (route.params.id) {
ids.conversationId = route.params.id as string;
getConversationDetails();
}
});
function getConversationDetails() {
api
.get('/userCenter/botConversation/detail', {
params: {
id: ids.conversationId,
},
})
.then((res) => {
if (res.errorCode === 0) {
conversationInfo.value = res.data;
ids.botId = res.data.botId;
getMessageList();
}
});
}
function getMessageList() {
api
.get('/userCenter/botMessage/getMessages', {
params: ids,
})
.then((res) => {
if (res.errorCode === 0) {
messageList.value = res.data;
loading.value = false;
}
});
}
async function handleShare() {
const shareLink = import.meta.env.DEV
? `${location.origin}/share/${ids.conversationId}`
: `${location.origin}/#/share/${ids.conversationId}`;
const { success, error } = await copyToClipboard(shareLink);
if (success) {
ElMessage.success('分享链接复制成功!');
} else {
ElMessage.error(error);
}
}
async function handleDelete() {
const [, res] = await tryit(api.post)('/userCenter/botConversation/remove', {
id: ids.conversationId,
});
if (res && res.errorCode === 0) {
ElMessage.success('删除成功');
router.back();
}
}
</script>
<template>
<ElContainer class="bg-background h-full">
<ElHeader height="100px" class="border-border border-b !pr-10">
<div class="flex h-full w-full items-center justify-between">
<!-- Left -->
<div class="flex items-center gap-3">
<ElButton
link
style="font-size: 20px"
:icon="ArrowLeft"
@click="router.back()"
/>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-lg font-medium">{{
conversationInfo?.title
}}</span>
<div
v-if="conversationInfo?.bot.title"
class="text-foreground/70 rounded bg-[var(--el-fill-color-light)] p-1 text-xs"
>
{{ conversationInfo.bot.title }}
</div>
</div>
<span class="text-foreground/50 text-sm">{{
conversationInfo?.created
}}</span>
</div>
</div>
<!-- Right -->
<div class="flex items-center gap-5">
<ElButton link style="font-size: 20px" @click="handleShare">
<template #icon>
<IconifyIcon icon="svg:share" />
</template>
</ElButton>
<ElDropdown>
<ElButton link style="font-size: 20px" :icon="MoreFilled" />
<template #dropdown>
<ElDropdownItem
style="color: var(--el-color-danger)"
:icon="Delete"
@click="handleDelete"
>
删除
</ElDropdownItem>
</template>
</ElDropdown>
</div>
</div>
</ElHeader>
<ElMain class="relative" v-loading="loading">
<div
class="absolute bottom-5 left-1/2 top-5 w-full max-w-[1000px] -translate-x-1/2"
>
<ChatBubbleList
:bot="conversationInfo?.bot"
:messages="messageList"
:editable="false"
:open-editor="() => {}"
/>
</div>
</ElMain>
</ElContainer>
</template>
<style lang="css" scoped>
:deep(.el-bubble-list) {
max-height: 100%;
}
:deep(.el-bubble-content-wrapper .el-bubble-content) {
--bubble-content-max-width: calc(100% - 52px);
}
</style>

View File

@@ -1,177 +1,367 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Delete, MoreFilled, Search } from '@element-plus/icons-vue';
import { Delete, Edit, Search } from '@element-plus/icons-vue';
import {
ElButton,
ElContainer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElDrawer,
ElEmpty,
ElHeader,
ElInput,
ElMain,
ElMessage,
ElMessageBox,
ElPagination,
ElSelect,
ElSpace,
ElText,
ElTag,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import PageData from '#/components/page/PageData.vue';
const listTitles = ['聊天助理名称', '话题', '创建时间', '操作'];
import { ChatBubbleList } from '#/components/chat';
const route = useRoute();
const router = useRouter();
const assistantList = ref<any[]>([]);
const queryParams = ref<any>({});
const pageRef = ref();
onMounted(() => {
getAssistantList();
const assistantList = ref<any[]>([]);
const sessions = ref<any[]>([]);
const loading = ref(false);
const queryParams = ref({
assistantId: undefined as number | undefined,
keyword: '',
pageNumber: 1,
pageSize: 20,
});
async function getAssistantList() {
api
.get('/userCenter/bot/list', {
params: { ...queryParams.value, status: 1 },
})
.then((res) => {
if (res.errorCode === 0) {
assistantList.value = res.data.map((item: any) => ({
label: item.title,
value: item.id,
}));
}
});
}
function search() {
pageRef.value.setQuery({ ...queryParams.value, status: 1 });
}
function toDetail(record: any) {
router.push({ path: `/chatHistory/${record.id}` });
}
async function handleDelete(id: string) {
const [, res] = await tryit(api.post)('/userCenter/botConversation/remove', {
id,
});
const pageState = ref({
total: 0,
});
if (res && res.errorCode === 0) {
search();
ElMessage.success('删除成功');
const drawerVisible = ref(false);
const drawerLoading = ref(false);
const currentSession = ref<any>();
const messageList = ref<any[]>([]);
const messagePage = ref({
total: 0,
pageNumber: 1,
pageSize: 20,
});
const filteredSessions = computed(() => {
const keyword = queryParams.value.keyword.trim().toLowerCase();
if (!keyword) {
return sessions.value;
}
return sessions.value.filter((item) => {
const title = String(item.title || '').toLowerCase();
const preview = String(item.lastMessagePreview || '').toLowerCase();
const assistantName = String(item.assistantName || '').toLowerCase();
return title.includes(keyword) || preview.includes(keyword) || assistantName.includes(keyword);
});
});
onMounted(async () => {
await Promise.all([fetchAssistants(), fetchSessions()]);
const sessionId = route.query.sessionId ? String(route.query.sessionId) : '';
if (sessionId) {
await openSession(sessionId);
}
});
watch(
() => route.query.sessionId,
async (sessionId) => {
if (!sessionId) {
drawerVisible.value = false;
currentSession.value = undefined;
messageList.value = [];
return;
}
if (!currentSession.value || String(currentSession.value.id) !== String(sessionId)) {
await openSession(String(sessionId));
}
},
);
async function fetchAssistants() {
const [, res] = await tryit(api.get)('/userCenter/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)('/userCenter/chatHistory/sessions', {
params: {
assistantId: queryParams.value.assistantId,
pageNumber: queryParams.value.pageNumber,
pageSize: queryParams.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: string | number) {
drawerLoading.value = true;
const [, summaryRes] = await tryit(api.get)(`/userCenter/chatHistory/sessions/${sessionId}`);
if (summaryRes?.errorCode !== 0) {
drawerLoading.value = false;
return;
}
currentSession.value = summaryRes.data;
messageList.value = [];
messagePage.value = {
total: 0,
pageNumber: 1,
pageSize: 20,
};
drawerVisible.value = true;
await loadMessages(true);
drawerLoading.value = false;
if (String(route.query.sessionId || '') !== String(sessionId)) {
router.replace({
path: '/chatHistory',
query: { ...route.query, sessionId: String(sessionId) },
});
}
}
async function loadMessages(reset = false) {
if (!currentSession.value?.id) {
return;
}
const nextPageNumber = reset ? 1 : messagePage.value.pageNumber + 1;
const [, res] = await tryit(api.get)(
`/userCenter/chatHistory/sessions/${currentSession.value.id}/messages`,
{
params: {
pageNumber: nextPageNumber,
pageSize: messagePage.value.pageSize,
},
},
);
if (res?.errorCode !== 0) {
return;
}
const normalized = normalizeMessages(res.data?.records || []);
if (reset) {
messageList.value = normalized;
} else {
messageList.value = [...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,
placement: item.senderRole === 'assistant' ? 'start' : 'end',
created: item.created,
chains: Array.isArray(item.contentPayload?.chains)
? item.contentPayload.chains
: undefined,
}));
}
function closeDrawer() {
drawerVisible.value = false;
currentSession.value = undefined;
messageList.value = [];
router.replace({ path: '/chatHistory', query: {} });
}
function changePage(pageNumber: number) {
queryParams.value.pageNumber = pageNumber;
fetchSessions();
}
async function renameSession(session: any) {
const [, promptRes] = await tryit(ElMessageBox.prompt)(
'请输入新的会话名称',
'重命名会话',
{
inputValue: session.title || '',
inputPlaceholder: '请输入会话名称',
confirmButtonText: '确认',
cancelButtonText: '取消',
},
);
const value = promptRes?.value?.trim();
if (!value) {
return;
}
const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/rename`, {
title: value,
});
if (res?.errorCode === 0) {
ElMessage.success('重命名成功');
if (currentSession.value?.id === session.id) {
currentSession.value.title = value;
}
await fetchSessions();
}
}
function deleteSession(session: any) {
ElMessageBox.confirm('删除后将不再出现在聊天历史中,是否继续?', '删除会话', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
}).then(async () => {
const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/delete`, {});
if (res?.errorCode === 0) {
ElMessage.success('删除成功');
if (currentSession.value?.id === session.id) {
closeDrawer();
}
await fetchSessions();
}
}).catch(() => {});
}
function formatTime(value?: string) {
if (!value) {
return '';
}
const time = new Date(value);
if (Number.isNaN(time.getTime())) {
return value;
}
return `${time.getMonth() + 1}-${time.getDate()} ${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
}
</script>
<template>
<ElContainer class="bg-background-deep h-full">
<ElHeader class="!h-auto !p-8 !pb-0">
<ElSpace direction="vertical" :size="24" alignment="flex-start">
<h1 class="text-2xl font-medium">聊天记录</h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-4">
<span class="text-nowrap text-sm">聊天助理</span>
<ElSelect
clearable
v-model="queryParams.botId"
:options="assistantList"
placeholder="请选择聊天助理"
@change="search"
/>
</div>
<ElHeader class="!h-auto !px-8 !pb-0 !pt-8">
<ElSpace direction="vertical" :size="20" alignment="flex-start" class="w-full">
<div>
<h1 class="text-2xl font-medium">聊天历史</h1>
<p class="text-foreground/60 mt-2 text-sm">
查看最近会话并在右侧抽屉中回溯完整聊天内容
</p>
</div>
<div class="flex w-full flex-wrap items-center gap-4">
<ElSelect
v-model="queryParams.assistantId"
clearable
placeholder="筛选聊天助理"
:options="assistantList"
class="!w-[220px]"
@change="fetchSessions"
/>
<ElInput
placeholder="搜索关键词"
v-model="queryParams.title"
@keyup.enter="search"
@change="search"
v-model="queryParams.keyword"
class="max-w-[320px]"
placeholder="搜索标题或最近消息"
:prefix-icon="Search"
/>
</div>
</ElSpace>
</ElHeader>
<ElMain class="!px-8">
<ElContainer class="bg-background rounded-lg p-5">
<ElHeader
class="dark:bg-accent grid grid-cols-[repeat(3,minmax(0,1fr))_120px] place-items-center rounded-lg bg-[#f7f9fd] !p-0"
height="54px"
>
<span
class="text-accent-foreground text-sm"
v-for="title in listTitles"
:key="title"
<ElMain class="!px-8 !pb-8">
<div class="bg-background border-border min-h-full rounded-2xl border p-5">
<div v-if="filteredSessions.length > 0" class="flex flex-col gap-3">
<button
v-for="item in filteredSessions"
:key="item.id"
class="border-border hover:border-primary/30 hover:bg-accent/40 flex w-full items-start justify-between gap-4 rounded-2xl border px-5 py-4 text-left transition-colors"
@click="openSession(item.id)"
>
{{ title }}
</span>
</ElHeader>
<ElMain class="!p-0">
<div class="flex flex-col items-center gap-5">
<div class="w-full">
<PageData
page-url="/userCenter/botConversation/pageList"
ref="pageRef"
>
<template #default="{ pageList }">
<div
class="text-foreground/90 grid h-[60px] grid-cols-[repeat(3,minmax(0,1fr))_120px] place-items-center text-sm hover:bg-[var(--el-fill-color-light)]"
v-for="record in pageList"
:key="record.id"
>
<ElText truncated>{{ record.bot.title }}</ElText>
<ElText line-clamp="2">{{ record.title }}</ElText>
<span>{{ record.created }}</span>
<div class="flex items-center gap-3">
<ElButton
class="[--el-font-weight-primary:400]"
link
type="primary"
@click="toDetail(record)"
>
查看详情
</ElButton>
<ElDropdown>
<ElButton :icon="MoreFilled" link />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem>
<ElButton
link
type="danger"
:icon="Delete"
@click="handleDelete(record.id)"
>
删除
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
</template>
</PageData>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3">
<span class="truncate text-base font-medium">{{ item.title || '未命名会话' }}</span>
<ElTag size="small" effect="plain">{{ item.assistantName || '聊天助理' }}</ElTag>
</div>
<div class="text-foreground/65 mt-2 line-clamp-2 text-sm">
{{ item.lastMessagePreview || '暂无消息内容' }}
</div>
<div class="text-foreground/50 mt-3 flex flex-wrap items-center gap-4 text-xs">
<span>最近发送人{{ item.lastSenderName || '未知' }}</span>
<span>消息数{{ item.messageCount || 0 }}</span>
<span>活跃时间{{ formatTime(item.lastMessageAt || item.accessAt) }}</span>
</div>
</div>
</div>
</ElMain>
</ElContainer>
<div class="flex items-center gap-2">
<ElButton link :icon="Edit" @click.stop="renameSession(item)">重命名</ElButton>
<ElButton link type="danger" :icon="Delete" @click.stop="deleteSession(item)">删除</ElButton>
</div>
</button>
</div>
<ElEmpty v-else description="暂无聊天历史" />
<div class="mt-6 flex justify-end" v-if="pageState.total > queryParams.pageSize">
<ElPagination
background
layout="prev, pager, next"
:current-page="queryParams.pageNumber"
:page-size="queryParams.pageSize"
:total="pageState.total"
@current-change="changePage"
/>
</div>
</div>
</ElMain>
</ElContainer>
<ElDrawer
v-model="drawerVisible"
:title="currentSession?.title || '聊天详情'"
size="760px"
destroy-on-close
@close="closeDrawer"
>
<div v-loading="drawerLoading" class="flex h-full flex-col">
<div class="border-border mb-4 flex items-center justify-between rounded-2xl border px-4 py-3">
<div class="min-w-0">
<div class="truncate text-base font-medium">{{ currentSession?.title || '聊天详情' }}</div>
<div class="text-foreground/55 mt-1 text-sm">
{{ currentSession?.assistantName || '聊天助理' }}
</div>
</div>
<div class="text-foreground/50 text-xs">
{{ formatTime(currentSession?.lastMessageAt || currentSession?.accessAt) }}
</div>
</div>
<div class="flex-1 overflow-hidden">
<div class="mb-4 flex justify-center">
<ElButton
v-if="messageList.length < messagePage.total"
text
type="primary"
@click="loadMessages(false)"
>
加载更早消息
</ElButton>
</div>
<ChatBubbleList
:bot="{ icon: '', title: currentSession?.assistantName || '' }"
:messages="messageList"
/>
</div>
</div>
</ElDrawer>
</template>
<style lang="css" scoped>
.el-select {
--el-select-width: 165px;
}
.el-select.bot-select {
--el-select-width: 343px;
}
.el-select :deep(.el-select__wrapper) {
--el-border-radius-base: 8px;
}
</style>

View File

@@ -1,127 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ElContainer, ElHeader, ElMain } from 'element-plus';
import { api } from '#/api/request';
import { ChatBubbleList } from '#/components/chat';
const route = useRoute();
const ids = reactive({
botId: '',
conversationId: '',
});
const conversationInfo = ref<any>();
const messageList = ref<any[]>([]);
const loading = ref(true);
const logoUrl = `${import.meta.env.BASE_URL || '/'}logo.svg`;
onMounted(() => {
if (route.params.id) {
ids.conversationId = route.params.id as string;
getConversationDetails();
}
});
function getConversationDetails() {
api
.get('/userCenter/botConversation/detail', {
params: {
id: ids.conversationId,
},
})
.then((res) => {
if (res.errorCode === 0) {
conversationInfo.value = res.data;
ids.botId = res.data.botId;
getMessageList();
}
});
}
function getMessageList() {
api
.get('/userCenter/botMessage/getMessages', {
params: ids,
})
.then((res) => {
if (res.errorCode === 0) {
messageList.value = res.data;
loading.value = false;
}
});
}
</script>
<template>
<div class="h-full w-full px-12 py-8 max-sm:p-3">
<ElContainer class="bg-background h-full">
<ElHeader
height="80px"
class="rounded-xl bg-[#F8F8F9] !pr-9 max-sm:!h-16 max-sm:!pr-3"
>
<div class="flex h-full w-full items-center justify-between">
<!-- Left -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-lg font-medium max-sm:text-base">{{
conversationInfo?.title
}}</span>
<div
v-if="conversationInfo?.bot.title"
class="text-foreground/70 rounded bg-[#ECECEE] p-1 text-xs"
>
{{ conversationInfo.bot.title }}
</div>
</div>
<span class="text-foreground/50 text-sm max-sm:text-xs">{{
conversationInfo?.created
}}</span>
</div>
<!-- Right -->
<img :src="logoUrl" class="w-40 max-sm:w-28" />
</div>
</ElHeader>
<ElMain class="relative max-sm:mt-2 max-sm:!p-0" v-loading="loading">
<div
class="absolute bottom-5 left-1/2 top-5 w-full max-w-[1000px] -translate-x-1/2 max-sm:bottom-0 max-sm:top-0"
>
<ChatBubbleList
class="relative mx-auto h-full max-w-[1000px]"
:bot="conversationInfo?.bot"
:messages="messageList"
:editable="false"
:open-editor="() => {}"
/>
</div>
</ElMain>
</ElContainer>
</div>
</template>
<style lang="css" scoped>
:deep(.el-bubble-list) {
max-height: 100%;
}
:deep(.el-bubble-content-wrapper .el-bubble-content) {
--bubble-content-max-width: calc(100% - 52px);
}
@media not all and (min-width: 640px) {
:deep(.el-bubble) {
gap: 8px;
}
:deep(.el-avatar) {
width: 30px;
height: 30px;
}
:deep(.el-bubble-content-wrapper .el-bubble-content) {
--bubble-content-max-width: calc(100% - 38px);
}
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ChatThinkingBlockProps } from './types';
defineOptions({
name: 'ChatThinkingBlock',
});
const props = withDefaults(defineProps<ChatThinkingBlockProps>(), {
content: '',
disabled: false,
emptyBehavior: 'hide',
expanded: false,
label: '',
readonly: false,
status: 'end',
summary: '',
});
const emit = defineEmits<{
'update:expanded': [boolean];
}>();
const normalizedContent = computed(() =>
String(props.content || '')
.replace(/\r\n/g, '\n')
.replace(/^\s*\n+/, '')
.trimEnd(),
);
const shouldRender = computed(
() =>
normalizedContent.value.length > 0 || props.emptyBehavior === 'placeholder',
);
const expandedModel = computed({
get: () => props.expanded,
set: (value: boolean) => emit('update:expanded', value),
});
const computedLabel = computed(() => {
if (props.label) {
return props.label;
}
if (props.status === 'thinking') {
return '思考中';
}
if (props.status === 'error') {
return '思考异常';
}
return '已思考';
});
const computedSummary = computed(() => {
if (props.summary) {
return props.summary;
}
const source = normalizedContent.value
.split('\n')
.map((line) => line.trim())
.find(Boolean);
if (!source) {
return props.emptyBehavior === 'placeholder' ? '暂无思考内容' : '';
}
return source.length > 56 ? `${source.slice(0, 56)}...` : source;
});
const canToggle = computed(
() => !props.disabled && normalizedContent.value.length > 0,
);
function toggleExpanded() {
if (!canToggle.value) {
return;
}
expandedModel.value = !expandedModel.value;
}
</script>
<template>
<div
v-if="shouldRender"
class="chat-thinking-block"
:class="[
`is-${status}`,
{
'is-disabled': disabled,
'is-expanded': expandedModel,
'is-readonly': readonly,
},
]"
>
<button
type="button"
class="chat-thinking-block__trigger"
:disabled="!canToggle"
@click="toggleExpanded"
>
<span class="chat-thinking-block__leading">
<span class="chat-thinking-block__indicator" aria-hidden="true"></span>
<span class="chat-thinking-block__label">{{ computedLabel }}</span>
</span>
<span
v-if="!expandedModel && computedSummary"
class="chat-thinking-block__summary"
>
{{ computedSummary }}
</span>
<span
class="chat-thinking-block__chevron"
:class="{ 'is-open': expandedModel }"
aria-hidden="true"
></span>
</button>
<transition name="chat-thinking-block__body-transition">
<div
v-if="expandedModel && normalizedContent"
class="chat-thinking-block__body"
>
<div class="chat-thinking-block__content">
{{ normalizedContent }}
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.chat-thinking-block {
border: 1px solid hsl(var(--divider-faint) / 0.18);
border-radius: 16px;
background:
linear-gradient(
180deg,
hsl(var(--glass-tint) / 0.48) 0%,
hsl(var(--surface-panel) / 0.74) 100%
);
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 0.24),
0 10px 24px -24px hsl(var(--foreground) / 0.18);
backdrop-filter: blur(12px);
}
.chat-thinking-block__trigger {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 12px;
color: inherit;
text-align: left;
background: transparent;
border: 0;
border-radius: inherit;
transition: background-color 0.18s ease;
}
.chat-thinking-block__trigger:not(:disabled) {
cursor: pointer;
}
.chat-thinking-block__trigger:not(:disabled):hover {
background: hsl(var(--surface-contrast-soft) / 0.34);
}
.chat-thinking-block__trigger:disabled {
cursor: default;
}
.chat-thinking-block__leading {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.chat-thinking-block__indicator {
position: relative;
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 999px;
background: hsl(var(--text-muted) / 0.74);
}
.chat-thinking-block.is-thinking .chat-thinking-block__indicator {
background: hsl(var(--primary) / 0.82);
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
animation: chat-thinking-pulse 1.8s ease-in-out infinite;
}
.chat-thinking-block.is-error .chat-thinking-block__indicator {
background: hsl(var(--destructive) / 0.86);
box-shadow: 0 0 0 4px hsl(var(--destructive) / 0.1);
}
.chat-thinking-block__label {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
color: hsl(var(--text-strong));
white-space: nowrap;
}
.chat-thinking-block__summary {
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 1.3;
color: hsl(var(--text-muted));
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-thinking-block__chevron {
width: 9px;
height: 9px;
border-right: 1.5px solid hsl(var(--text-muted));
border-bottom: 1.5px solid hsl(var(--text-muted));
transform: rotate(45deg) translateY(-1px);
transition: transform 0.18s ease;
}
.chat-thinking-block__chevron.is-open {
transform: rotate(225deg) translateY(-1px);
}
.chat-thinking-block__body {
padding: 0 12px 12px;
}
.chat-thinking-block__content {
margin: 0;
padding: 10px 12px;
border-radius: 12px;
background: hsl(var(--surface-panel) / 0.72);
font-size: 12px;
line-height: 1.68;
color: hsl(var(--text-secondary));
white-space: pre-wrap;
word-break: break-word;
}
.chat-thinking-block.is-disabled {
opacity: 0.82;
}
.chat-thinking-block__body-transition-enter-active,
.chat-thinking-block__body-transition-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.chat-thinking-block__body-transition-enter-from,
.chat-thinking-block__body-transition-leave-to {
opacity: 0;
transform: translateY(-4px);
}
@keyframes chat-thinking-pulse {
0%,
100% {
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
opacity: 0.92;
}
50% {
box-shadow: 0 0 0 7px hsl(var(--primary) / 0.04);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,5 @@
export { default as ChatThinkingBlock } from './ChatThinkingBlock.vue';
export type {
ChatThinkingBlockProps,
ChatThinkingBlockStatus,
} from './types';

View File

@@ -0,0 +1,12 @@
export type ChatThinkingBlockStatus = 'end' | 'error' | 'thinking';
export interface ChatThinkingBlockProps {
content?: string;
disabled?: boolean;
emptyBehavior?: 'hide' | 'placeholder';
expanded?: boolean;
label?: string;
readonly?: boolean;
status?: ChatThinkingBlockStatus;
summary?: string;
}

View File

@@ -1,5 +1,6 @@
export * from './api-component';
export * from './captcha';
export * from './chat-thinking';
export * from './col-page';
export * from './count-to';
export * from './ellipsis-text';

View File

@@ -1,17 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="18px" height="16px" viewBox="0 0 18 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>聊天助理备份 7</title>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="聊天记录" transform="translate(-29, -463)">
<g id="编组-12备份-2" transform="translate(0, 3)">
<g id="编组-32备份-2" transform="translate(12, 444)">
<g id="聊天助理备份-7" transform="translate(16, 14)">
<rect id="矩形" x="0" y="0" width="20" height="20"></rect>
<path d="M15,2.8 C15.8836556,2.8 16.6836556,3.1581722 17.2627417,3.7372583 C17.8418278,4.3163444 18.2,5.1163444 18.2,6 L18.2,17.2 L5,17.2 C4.1163444,17.2 3.3163444,16.8418278 2.7372583,16.2627417 C2.1581722,15.6836556 1.8,14.8836556 1.8,14 L1.8,6 C1.8,5.1163444 2.1581722,4.3163444 2.7372583,3.7372583 C3.3163444,3.1581722 4.1163444,2.8 5,2.8 Z" id="形状结合" stroke="currentColor" stroke-width="1.6"></path>
<polyline id="路径-2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" points="8.8275 6.93789684 8.8275 10.9003453 12.5 10.9003453"></polyline>
</g>
</g>
</g>
</g>
</g>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.25"
>
<path d="M3 3.4h7.9A1.9 1.9 0 0 1 12.8 5.3v3.5a1.9 1.9 0 0 1-1.9 1.9H7.2l-2.4 2V10.7H4.9A1.9 1.9 0 0 1 3 8.8V3.4Z"/>
<path d="M5.3 5.9h2.2"/>
<circle cx="9.6" cy="7.2" r="1.95"/>
<path d="M9.6 6.2v1.2l.85.58"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -10,6 +10,7 @@ const SvgDownloadIcon = createIconifyIcon('svg:download');
const SvgCardIcon = createIconifyIcon('svg:card');
const SvgBellIcon = createIconifyIcon('svg:bell');
const SvgCakeIcon = createIconifyIcon('svg:cake');
const SvgChatHistoryIcon = createIconifyIcon('svg:chat-history');
const SvgAntdvLogoIcon = createIconifyIcon('svg:antdv-logo');
const SvgGithubIcon = createIconifyIcon('svg:github');
const SvgGoogleIcon = createIconifyIcon('svg:google');
@@ -27,6 +28,7 @@ export {
SvgBellIcon,
SvgCakeIcon,
SvgCardIcon,
SvgChatHistoryIcon,
SvgDingDingIcon,
SvgDownloadIcon,
SvgGithubIcon,