Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/chat/index.vue
陈子默 2907acac95 feat: 默认选择首个聊天智能体
- 拉取智能体列表后在无会话上下文时复用现有切换逻辑选择第一个智能体

- 保留历史会话和已选智能体的优先级
2026-05-18 10:00:25 +08:00

2559 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type {
ChatTimeHistoryRecord,
ChatTimeTimelineItem,
} from '@easyflow/types';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
createChatVariantSwitchController,
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
uuid,
} from '@easyflow/utils';
import {
Delete,
Plus,
Promotion,
Search,
} from '@element-plus/icons-vue';
import {
ElButton,
ElEmpty,
ElInput,
ElMessage,
ElMessageBox,
ElScrollbar,
ElTag,
} from 'element-plus';
import { tryit } from 'radash';
import { api, sseClient } from '#/api/request';
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
import ChatContextCapsuleBar from '#/components/chat-workspace/ChatContextCapsuleBar.vue';
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
import ChatWelcomeAssistantPicker from '#/components/chat-workspace/ChatWelcomeAssistantPicker.vue';
interface AssistantOption {
label: string;
raw: any;
value: string;
}
interface KnowledgeOption {
label: string;
value: string;
}
interface KnowledgeView {
alias?: string;
id: string;
title: string;
}
interface SessionItem {
accessAt?: string;
assistantCode?: string;
assistantId: string;
assistantName?: string;
continuable?: boolean;
lastMessageAt?: string;
lastMessagePreview?: string;
messageCount?: number;
readOnlyReason?: null | string;
sessionId: string;
title?: string;
}
interface OpenSessionOptions {
scrollToLatest?: boolean;
silent?: boolean;
syncRoute?: boolean;
}
interface WorkspaceConversationView {
records?: ChatTimeHistoryRecord[];
total?: number;
variantsByRound?: Record<string, ChatTimeHistoryRecord[]>;
}
interface SseRoundMeta {
regenerate?: boolean;
regenerateRoundId?: string;
roundId?: string;
roundNo?: number;
selectedVariantIndex?: number;
switchable?: boolean;
variantCount?: number;
variantIndex?: number;
}
const SESSION_PAGE_SIZE = 30;
const MESSAGE_AUTO_SCROLL_THRESHOLD = 48;
const SIDEBAR_LOAD_MORE_THRESHOLD = 120;
const route = useRoute();
const router = useRouter();
const assistants = ref<AssistantOption[]>([]);
const knowledgeOptions = ref<KnowledgeOption[]>([]);
const knowledgeMap = ref(new Map<string, KnowledgeView>());
const sessions = ref<SessionItem[]>([]);
const loadingSessions = ref(false);
const loadingMoreSessions = ref(false);
const loadingMessages = ref(false);
const loadingAssistants = ref(false);
const loadingKnowledgeOptions = ref(false);
const sending = ref(false);
const sessionSearchKeyword = ref('');
const currentAssistantId = ref<string>();
const currentAssistantDetail = ref<any>();
const currentSession = ref<any>();
const messageList = ref<ChatTimeTimelineItem[]>([]);
const extraKnowledgeIds = ref<string[]>([]);
const boundKnowledges = ref<KnowledgeView[]>([]);
const senderValue = ref('');
const sessionPage = ref({
pageNumber: 0,
pageSize: SESSION_PAGE_SIZE,
total: 0,
});
const openSessionRequestToken = ref(0);
const activeStreamSessionId = ref('');
const autoScrollEnabled = ref(true);
const optimisticSessionIds = ref<Set<string>>(new Set());
const variantSwitchStateVersion = ref(0);
const sessionContextMenuVisible = ref(false);
const sessionContextMenuTarget = ref<null | SessionItem>(null);
const sessionContextMenuX = ref(0);
const sessionContextMenuY = ref(0);
const variantSwitchController = createChatVariantSwitchController<
ChatTimeHistoryRecord,
ChatTimeTimelineItem
>({
mapRecords: (records) => ChatTimeHistoryMapper.fromHistoryRecords(records),
onError: () => ElMessage.error('答案版本切换失败'),
onStateChange: () => {
variantSwitchStateVersion.value += 1;
},
replaceRound: (items, roundId, nextItems) =>
ChatTimeTimelineBuilder.replaceRoundMessages(items, roundId, nextItems),
});
const currentSessionId = computed(() => {
const queryValue = route.query.sessionId;
if (!queryValue) {
return '';
}
return String(queryValue);
});
const hasMessages = computed(() => messageList.value.length > 0);
const currentDisplayAssistant = computed(
() => currentSession.value?.assistant || currentAssistantDetail.value,
);
const composerAssistantName = computed(() => {
if (currentAssistantDetail.value?.title) {
return currentAssistantDetail.value.title;
}
return assistants.value.find((item) => item.value === currentAssistantId.value)?.label || '';
});
const activeAssistantName = computed(() => {
if (currentDisplayAssistant.value?.title) {
return currentDisplayAssistant.value.title;
}
if (currentDisplayAssistant.value?.name) {
return currentDisplayAssistant.value.name;
}
if (currentSession.value?.assistantName) {
return currentSession.value.assistantName;
}
return composerAssistantName.value;
});
const currentSessionTitle = computed(() => currentSession.value?.title || '新对话');
const isReadOnly = computed(() => currentSession.value?.continuable === false);
const isWelcomeComposerState = computed(() => {
return !hasMessages.value && !currentSession.value?.sessionId;
});
const composerLocked = computed(() => isReadOnly.value || !currentAssistantId.value);
const contextSelectorDisabled = computed(() => isReadOnly.value || sending.value);
const canCreateSession = computed(() => !sending.value);
const canSend = computed(
() =>
!sending.value &&
!composerLocked.value &&
!!currentAssistantId.value &&
!!senderValue.value.trim(),
);
const hasMoreSessionRecords = computed(
() => sessions.value.length < (sessionPage.value.total || 0),
);
const canLoadMoreSessions = computed(
() =>
!loadingSessions.value &&
!loadingMoreSessions.value &&
hasMoreSessionRecords.value,
);
const selectedSessionKey = computed(() => {
return String(currentSession.value?.sessionId || '');
});
const latestAssistantMessage = computed(() => {
return [...messageList.value]
.reverse()
.find((item) => item.role === 'assistant');
});
const latestAssistantMessageId = computed(
() => latestAssistantMessage.value?.id || '',
);
const displayedBoundKnowledges = computed(() => {
if (!isReadOnly.value) {
return boundKnowledges.value;
}
return (currentSession.value?.boundKnowledges || []).map((item: any) => ({
alias: item.alias,
id: String(item.id),
title: item.title,
}));
});
const displayedExtraKnowledgeIds = computed(() => {
if (!isReadOnly.value) {
return extraKnowledgeIds.value;
}
return (currentSession.value?.extraKnowledges || []).map((item: any) =>
String(item.id),
);
});
const displayedExtraKnowledges = computed(() => {
if (isReadOnly.value) {
return (currentSession.value?.extraKnowledges || []).map((item: any) => ({
alias: item.alias,
id: String(item.id),
title: item.title,
}));
}
return extraKnowledgeIds.value
.map((id) => resolveKnowledgeView(id))
.filter(Boolean) as KnowledgeView[];
});
const editableKnowledgeIds = computed(() => {
return isReadOnly.value ? [] : extraKnowledgeIds.value;
});
const extraKnowledgeOptionList = computed(() => {
const excluded = new Set(boundKnowledges.value.map((item) => item.id));
for (const selectedId of extraKnowledgeIds.value) {
excluded.add(String(selectedId));
}
return knowledgeOptions.value.filter((item) => !excluded.has(String(item.value)));
});
const filteredSessions = computed(() => {
const keyword = sessionSearchKeyword.value.trim().toLowerCase();
if (!keyword) {
return sessions.value;
}
return sessions.value.filter((session) => {
return [
resolveSessionLabel(session),
session.lastMessagePreview,
session.assistantName,
].some((value) => String(value || '').toLowerCase().includes(keyword));
});
});
const groupedSessions = computed(() => {
const today: SessionItem[] = [];
const recent: SessionItem[] = [];
const earlier: SessionItem[] = [];
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const recentThreshold = now.getTime() - 30 * 24 * 60 * 60 * 1000;
for (const session of filteredSessions.value) {
const time = resolveSessionSortTime(session);
if (!Number.isFinite(time)) {
earlier.push(session);
continue;
}
if (time >= startOfToday) {
today.push(session);
continue;
}
if (time >= recentThreshold) {
recent.push(session);
continue;
}
earlier.push(session);
}
return [
{ key: 'today', title: '今天', items: today },
{ key: 'recent', title: '最近一个月', items: recent },
{ key: 'earlier', title: '更早', items: earlier },
].filter((group) => group.items.length > 0);
});
const greetingPrefix = computed(() => {
const hour = new Date().getHours();
if (hour < 12) {
return '上午好';
}
if (hour < 18) {
return '下午好';
}
return '晚上好';
});
const emptyConversationTitle = computed(() => {
if (activeAssistantName.value) {
return `开始和 ${activeAssistantName.value} 对话`;
}
return '选择智能体后开始对话';
});
const emptyConversationText = computed(() => {
if (activeAssistantName.value) {
return '输入消息后开始第一轮协作。';
}
return '先在欢迎区选择一个可用智能体。';
});
const composerPlaceholder = computed(() => {
if (isReadOnly.value) {
return resolveReadOnlyText(currentSession.value?.readOnlyReason) || '当前会话只支持查看历史';
}
if (!currentAssistantId.value) {
return '请先选择智能体';
}
return '发送消息';
});
onMounted(async () => {
window.addEventListener('click', closeSessionContextMenu);
window.addEventListener('resize', closeSessionContextMenu);
window.addEventListener('scroll', closeSessionContextMenu, true);
window.addEventListener('keydown', handleWindowKeydown);
await Promise.all([fetchAssistants(), fetchKnowledgeOptions(), fetchSessions()]);
if (currentSessionId.value) {
await openSessionById(currentSessionId.value, {
scrollToLatest: false,
silent: true,
});
}
});
watch(
() => route.query.sessionId,
async (sessionId) => {
const normalized = sessionId ? String(sessionId) : '';
if (!normalized) {
return;
}
if (selectedSessionKey.value === normalized) {
return;
}
await openSessionById(normalized, {
scrollToLatest: false,
silent: true,
});
},
);
onBeforeUnmount(() => {
window.removeEventListener('click', closeSessionContextMenu);
window.removeEventListener('resize', closeSessionContextMenu);
window.removeEventListener('scroll', closeSessionContextMenu, true);
window.removeEventListener('keydown', handleWindowKeydown);
});
async function fetchAssistants() {
loadingAssistants.value = true;
const [, res] = await tryit(api.get)('/api/v1/bot/list', {
params: {
publishedOnly: true,
status: 1,
},
});
loadingAssistants.value = false;
if (res?.errorCode === 0) {
assistants.value = (res.data || []).map((item: any) => ({
label: item.title,
raw: item,
value: String(item.id),
}));
await selectDefaultAssistantIfNeeded();
}
}
async function selectDefaultAssistantIfNeeded() {
if (
currentSessionId.value ||
currentSession.value?.sessionId ||
currentAssistantId.value ||
assistants.value.length === 0
) {
return;
}
await handleAssistantChange(assistants.value[0]?.value);
}
async function fetchKnowledgeOptions() {
loadingKnowledgeOptions.value = true;
const [, res] = await tryit(api.get)('/api/v1/documentCollection/list', {
params: {
publishedOnly: true,
},
});
loadingKnowledgeOptions.value = false;
if (res?.errorCode === 0) {
knowledgeOptions.value = (res.data || []).map((item: any) => ({
label: item.title,
value: String(item.id),
}));
knowledgeMap.value = new Map(
(res.data || []).map((item: any) => [
String(item.id),
{
alias: item.alias,
id: String(item.id),
title: item.title,
} satisfies KnowledgeView,
]),
);
if (currentAssistantDetail.value && !isReadOnly.value) {
boundKnowledges.value = resolveBoundKnowledgesFromBot(currentAssistantDetail.value);
extraKnowledgeIds.value = extraKnowledgeIds.value.filter(
(id) => !boundKnowledges.value.some((item) => item.id === id),
);
}
}
}
async function fetchSessions(options: { append?: boolean } = {}) {
const append = options.append === true;
if (append) {
if (!canLoadMoreSessions.value) {
return;
}
loadingMoreSessions.value = true;
} else {
loadingSessions.value = true;
}
const nextPageNumber = append ? sessionPage.value.pageNumber + 1 : 1;
const [, res] = await tryit(api.get)('/api/v1/chatWorkspace/sessions', {
params: {
pageNumber: nextPageNumber,
pageSize: sessionPage.value.pageSize,
},
});
if (append) {
loadingMoreSessions.value = false;
} else {
loadingSessions.value = false;
}
if (res?.errorCode === 0) {
const records = (res.data?.records || []) as SessionItem[];
sessions.value = append
? mergeSessionRecords(sessions.value, records)
: mergeFreshSessionRecords(records);
sessionPage.value.pageNumber = Number(res.data?.pageNumber || nextPageNumber);
sessionPage.value.total = Math.max(
Number(res.data?.total || sessions.value.length),
sessions.value.length,
);
}
}
function mergeFreshSessionRecords(records: SessionItem[]) {
const freshIds = new Set(records.map((item) => String(item.sessionId)));
const optimisticIds = new Set(optimisticSessionIds.value);
for (const sessionId of optimisticIds) {
if (freshIds.has(sessionId)) {
optimisticIds.delete(sessionId);
}
}
optimisticSessionIds.value = optimisticIds;
const preserved = sessions.value.filter((item) => {
const sessionId = String(item.sessionId);
return optimisticIds.has(sessionId) && !freshIds.has(sessionId);
});
return sortSessionRecords([...records, ...preserved]);
}
function mergeSessionRecords(current: SessionItem[], incoming: SessionItem[]) {
const merged = new Map<string, SessionItem>();
for (const item of [...current, ...incoming]) {
const sessionId = String(item.sessionId);
if (!merged.has(sessionId)) {
merged.set(sessionId, {
...item,
sessionId,
});
}
}
return sortSessionRecords(Array.from(merged.values()));
}
function upsertSessionRecord(
session: SessionItem,
options: { incrementTotal?: boolean } = {},
) {
const normalizedSession = {
...session,
assistantId: String(session.assistantId || ''),
sessionId: String(session.sessionId),
} satisfies SessionItem;
const next = [...sessions.value];
const currentIndex = next.findIndex(
(item) => String(item.sessionId) === normalizedSession.sessionId,
);
const existed = currentIndex >= 0;
if (existed) {
next.splice(currentIndex, 1);
}
next.push({
...(existed ? sessions.value[currentIndex] : {}),
...normalizedSession,
});
sessions.value = sortSessionRecords(next);
if (!existed && options.incrementTotal) {
sessionPage.value.total += 1;
} else {
sessionPage.value.total = Math.max(sessionPage.value.total, sessions.value.length);
}
}
function resolveSessionSortTime(session: SessionItem) {
const time = new Date(session.lastMessageAt || '').getTime();
return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
}
function sortSessionRecords(records: SessionItem[]) {
return [...records].sort((a, b) => {
const timeDiff = resolveSessionSortTime(b) - resolveSessionSortTime(a);
if (timeDiff !== 0) {
return timeDiff;
}
return String(b.sessionId || '').localeCompare(String(a.sessionId || ''));
});
}
function createOptimisticSessionRecord(sessionId: string, prompt: string) {
const now = new Date().toISOString();
const normalizedPrompt = prompt.trim();
return {
accessAt: now,
assistantCode: currentSession.value?.assistantCode || currentAssistantDetail.value?.alias,
assistantId: String(currentAssistantId.value || ''),
assistantName: activeAssistantName.value || composerAssistantName.value,
continuable: true,
lastMessageAt: now,
lastMessagePreview: normalizedPrompt,
messageCount: Math.max(Number(currentSession.value?.messageCount || 0), 0) + 1,
readOnlyReason: null,
sessionId,
title: currentSession.value?.title || '新对话',
} satisfies SessionItem;
}
function syncCurrentSessionRecord(partial: Partial<SessionItem> & { sessionId: string }) {
const currentId = String(partial.sessionId);
const existing = sessions.value.find((item) => String(item.sessionId) === currentId);
if (!existing) {
upsertSessionRecord(
{
accessAt:
partial.accessAt ||
currentSession.value?.accessAt ||
currentSession.value?.lastMessageAt ||
new Date().toISOString(),
assistantId: String(partial.assistantId || currentAssistantId.value || ''),
assistantName: partial.assistantName || activeAssistantName.value,
continuable: partial.continuable ?? currentSession.value?.continuable ?? true,
lastMessageAt:
partial.lastMessageAt ||
currentSession.value?.lastMessageAt ||
new Date().toISOString(),
lastMessagePreview: partial.lastMessagePreview || '',
messageCount: Number(partial.messageCount || currentSession.value?.messageCount || 0),
readOnlyReason: partial.readOnlyReason ?? currentSession.value?.readOnlyReason,
sessionId: currentId,
title: partial.title || currentSession.value?.title || '新对话',
} as SessionItem,
{ incrementTotal: true },
);
return;
}
upsertSessionRecord(
{
...existing,
...partial,
assistantId: String(partial.assistantId || existing.assistantId || ''),
sessionId: currentId,
},
);
}
function removeSessionRecord(sessionId: string) {
const currentId = String(sessionId);
optimisticSessionIds.value = new Set(
[...optimisticSessionIds.value].filter((item) => item !== currentId),
);
const next = sessions.value.filter((item) => String(item.sessionId) !== currentId);
if (next.length === sessions.value.length) {
return;
}
sessions.value = next;
sessionPage.value.total = Math.max(sessionPage.value.total - 1, sessions.value.length);
}
function resetComposerState(options?: { resetAssistant?: boolean }) {
abortActiveStream();
autoScrollEnabled.value = true;
if (options?.resetAssistant !== false) {
currentAssistantId.value = undefined;
currentAssistantDetail.value = undefined;
boundKnowledges.value = [];
extraKnowledgeIds.value = [];
}
currentSession.value = undefined;
messageList.value = [];
senderValue.value = '';
}
async function enterWelcomeState() {
resetComposerState();
await router.replace({ query: {} });
}
async function handleAssistantChange(assistantId?: string) {
const normalized = assistantId ? String(assistantId) : undefined;
if (!isReadOnly.value && normalized === currentAssistantId.value) {
return;
}
if (!normalized) {
await enterWelcomeState();
return;
}
abortActiveStream();
const assistant = await fetchAssistantDetail(normalized);
if (!assistant?.id) {
ElMessage.warning('聊天助手不存在或已下架');
await enterWelcomeState();
return;
}
currentAssistantId.value = normalized;
const assistantName =
assistant.title ||
assistant.name ||
assistants.value.find((item) => item.value === normalized)?.label;
currentAssistantDetail.value = assistant;
boundKnowledges.value = resolveBoundKnowledgesFromBot(assistant);
extraKnowledgeIds.value = [];
currentSession.value = undefined;
messageList.value = [];
senderValue.value = '';
if (assistantName) {
currentAssistantDetail.value = {
...assistant,
title: assistantName,
};
}
await router.replace({ query: {} });
}
async function fetchAssistantDetail(assistantId: string) {
const [, res] = await tryit(api.get)('/api/v1/bot/getDetail', {
params: {
id: assistantId,
publishedOnly: true,
},
});
if (res?.errorCode !== 0 || !res?.data) {
return null;
}
return res.data;
}
async function ensureConversationSession() {
if (currentSession.value?.sessionId) {
return String(currentSession.value.sessionId);
}
const [, res] = await tryit(api.get)('/api/v1/bot/generateConversationId');
if (res?.errorCode !== 0 || !res.data) {
throw new Error('暂时无法创建会话,请稍后再试');
}
const sessionId = String(res.data);
currentSession.value = {
assistant: currentAssistantDetail.value
? {
alias: currentAssistantDetail.value.alias,
description: currentAssistantDetail.value.description,
icon: currentAssistantDetail.value.icon,
id: currentAssistantId.value,
title: composerAssistantName.value,
}
: undefined,
assistantId: currentAssistantId.value,
assistantName: composerAssistantName.value,
boundKnowledges: [...boundKnowledges.value],
continuable: true,
extraKnowledges: displayedExtraKnowledges.value,
readOnlyReason: null,
sessionId,
title: '新对话',
};
return sessionId;
}
async function handleCreateSession() {
await enterWelcomeState();
}
async function syncSessionRoute(sessionId?: string) {
const normalized = String(sessionId || '');
if (!normalized || currentSessionId.value === normalized) {
return;
}
await router.replace({
query: {
sessionId: normalized,
},
});
}
async function requestSessionDetail(sessionId: string) {
const [, detailRes] = await tryit(api.get)(
`/api/v1/chatWorkspace/sessions/${sessionId}`,
);
if (detailRes?.errorCode !== 0) {
return null;
}
return detailRes.data;
}
async function requestConversation(sessionId: string) {
const [, res] = await tryit(api.get)(
`/api/v1/chatWorkspace/sessions/${sessionId}/conversation`,
);
if (res?.errorCode !== 0) {
return null;
}
return (res.data || {}) as WorkspaceConversationView;
}
function applyConversation(
conversation: WorkspaceConversationView | null,
sessionId: string,
) {
const records = Array.isArray(conversation?.records)
? conversation.records
: [];
messageList.value = ChatTimeHistoryMapper.fromHistoryRecords(records);
seedVariantCache(sessionId, conversation?.variantsByRound || {});
}
function seedVariantCache(
sessionId: string,
variantsByRound: Record<string, ChatTimeHistoryRecord[]>,
) {
for (const [roundId, variants] of Object.entries(variantsByRound || {})) {
if (!roundId || !Array.isArray(variants)) {
continue;
}
variantSwitchController.cacheVariants(sessionId, roundId, variants);
}
}
function applySessionDetail(detail: any, options?: { resetSender?: boolean }) {
currentSession.value = detail;
if (options?.resetSender !== false) {
senderValue.value = '';
}
if (detail.continuable !== false) {
currentAssistantId.value = detail.assistant?.id
? String(detail.assistant.id)
: undefined;
currentAssistantDetail.value = detail.assistant;
boundKnowledges.value = (detail.boundKnowledges || []).map((item: any) => ({
alias: item.alias,
id: String(item.id),
title: item.title,
}));
extraKnowledgeIds.value = (detail.extraKnowledges || []).map((item: any) =>
String(item.id),
);
} else {
currentAssistantId.value = undefined;
currentAssistantDetail.value = undefined;
boundKnowledges.value = [];
extraKnowledgeIds.value = [];
}
}
async function openSession(item: SessionItem) {
closeSessionContextMenu();
await openSessionById(String(item.sessionId), {
scrollToLatest: true,
syncRoute: true,
});
}
function closeSessionContextMenu() {
sessionContextMenuVisible.value = false;
sessionContextMenuTarget.value = null;
}
function openSessionContextMenu(event: MouseEvent, session: SessionItem) {
event.preventDefault();
sessionContextMenuTarget.value = session;
sessionContextMenuX.value = Math.min(event.clientX, window.innerWidth - 188);
sessionContextMenuY.value = Math.min(event.clientY, window.innerHeight - 116);
sessionContextMenuVisible.value = true;
}
function openSessionContextMenuByKeyboard(
event: KeyboardEvent,
session: SessionItem,
) {
const target = event.currentTarget as HTMLElement | null;
if (!target) {
return;
}
const rect = target.getBoundingClientRect();
sessionContextMenuTarget.value = session;
sessionContextMenuX.value = Math.min(rect.right - 12, window.innerWidth - 188);
sessionContextMenuY.value = Math.min(rect.top + 12, window.innerHeight - 116);
sessionContextMenuVisible.value = true;
}
function handleSessionItemKeydown(event: KeyboardEvent, session: SessionItem) {
if (event.key === 'Enter') {
event.preventDefault();
openSession(session);
return;
}
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
event.preventDefault();
openSessionContextMenuByKeyboard(event, session);
}
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeSessionContextMenu();
}
}
async function openSessionById(
sessionId: string,
options: OpenSessionOptions = {},
) {
abortActiveStream();
const requestToken = ++openSessionRequestToken.value;
loadingMessages.value = true;
const detail = await requestSessionDetail(sessionId);
if (requestToken !== openSessionRequestToken.value) {
return false;
}
if (!detail) {
loadingMessages.value = false;
if (!options.silent) {
ElMessage.warning('会话不存在或已删除');
}
if (currentSessionId.value === sessionId) {
await router.replace({ query: {} });
}
return false;
}
applySessionDetail(detail);
syncCurrentSessionRecord({
accessAt: detail.accessAt,
assistantCode: detail.assistantCode,
assistantId: detail.assistantId ? String(detail.assistantId) : undefined,
assistantName: detail.assistantName,
continuable: detail.continuable,
lastMessageAt: detail.lastMessageAt,
lastMessagePreview: detail.lastMessagePreview,
messageCount: detail.messageCount,
readOnlyReason: detail.readOnlyReason,
sessionId,
title: detail.title || '新对话',
});
if ((detail.removedExtraKnowledgeNames || []).length > 0) {
ElMessage.warning(
`以下知识库已失效并移除:${detail.removedExtraKnowledgeNames.join('、')}`,
);
}
const conversation = await requestConversation(sessionId);
if (requestToken !== openSessionRequestToken.value) {
return false;
}
if (!conversation) {
loadingMessages.value = false;
if (!options.silent) {
ElMessage.warning('会话内容加载失败');
}
return false;
}
applyConversation(conversation, sessionId);
loadingMessages.value = false;
if (options.syncRoute) {
await syncSessionRoute(sessionId);
}
if (options.scrollToLatest !== false) {
scrollToBottom(true);
}
return true;
}
function getMessageScrollWrap() {
return document.querySelector(
'.chat-workspace__message-scroll .el-scrollbar__wrap',
) as HTMLElement | null;
}
function getSidebarScrollWrap() {
return document.querySelector(
'.chat-workspace__sidebar-scroll .el-scrollbar__wrap',
) as HTMLElement | null;
}
function isNearMessageBottom(element: HTMLElement) {
const distanceToBottom =
element.scrollHeight - (element.scrollTop + element.clientHeight);
return distanceToBottom <= MESSAGE_AUTO_SCROLL_THRESHOLD;
}
function handleMessageScroll() {
const element = getMessageScrollWrap();
if (!element) {
autoScrollEnabled.value = true;
return;
}
autoScrollEnabled.value = isNearMessageBottom(element);
}
function handleSidebarScroll() {
closeSessionContextMenu();
const element = getSidebarScrollWrap();
if (!element || !canLoadMoreSessions.value) {
return;
}
const distanceToBottom =
element.scrollHeight - (element.scrollTop + element.clientHeight);
if (distanceToBottom <= SIDEBAR_LOAD_MORE_THRESHOLD) {
fetchSessions({ append: true });
}
}
function scrollToBottom(force = false) {
requestAnimationFrame(() => {
const element = getMessageScrollWrap();
if (element) {
if (!force && !autoScrollEnabled.value) {
return;
}
element.scrollTop = element.scrollHeight;
autoScrollEnabled.value = true;
}
});
}
async function copyMessageContent(content?: string) {
const normalized = String(content || '').trim();
if (!normalized) {
return;
}
try {
await navigator.clipboard.writeText(normalized);
ElMessage.success('已复制');
} catch {
ElMessage.error('复制失败');
}
}
function resolveKnowledgeView(knowledgeId: string) {
const view = knowledgeMap.value.get(knowledgeId);
if (view) {
return view;
}
const option = knowledgeOptions.value.find(
(item) => String(item.value) === knowledgeId,
);
if (!option) {
return null;
}
return {
id: knowledgeId,
title: option.label,
} satisfies KnowledgeView;
}
function resolveBoundKnowledgesFromBot(bot: any): KnowledgeView[] {
const bindings = bot?.publishedSnapshotJson?.knowledgeBindings;
if (!Array.isArray(bindings)) {
return [];
}
const list: KnowledgeView[] = [];
const seen = new Set<string>();
for (const binding of bindings) {
const knowledgeId = String(binding?.knowledgeId || '');
if (!knowledgeId || seen.has(knowledgeId)) {
continue;
}
const knowledge = resolveKnowledgeView(knowledgeId);
if (!knowledge) {
continue;
}
seen.add(knowledgeId);
list.push(knowledge);
}
return list;
}
function resolveReadOnlyText(reason?: null | string) {
if (reason === 'NO_PERMISSION') {
return '当前会话对应的聊天助手已无权限,仅支持查看历史记录';
}
if (reason === 'ASSISTANT_OFFLINE') {
return '当前会话对应的聊天助手已下架,仅支持查看历史记录';
}
if (reason === 'ASSISTANT_DELETED') {
return '当前会话对应的聊天助手已删除,仅支持查看历史记录';
}
return '';
}
function resolveSessionTimeLabel(session: SessionItem) {
return formatTime(session.lastMessageAt || session.accessAt);
}
function resolveSessionLabel(session: SessionItem) {
return session.title || session.lastMessagePreview || '未命名会话';
}
function resolveSessionSubtitle(session: SessionItem) {
return session.lastMessagePreview || session.assistantName || '暂无消息';
}
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')}`;
}
function resolveLastUserPrompt() {
const reversed = [...messageList.value].reverse();
const item = reversed.find((entry) => entry.role === 'user');
return item?.content?.trim() || '';
}
function resolveRoundUserPrompt(roundId?: string) {
if (!roundId) {
return resolveLastUserPrompt();
}
const reversed = [...messageList.value].reverse();
const item = reversed.find(
(entry) => entry.role === 'user' && entry.roundId === roundId,
);
return item?.content?.trim() || '';
}
function appendUserMessage(content: string) {
const userMessageId = uuid();
ChatTimeTimelineBuilder.appendUserMessage(messageList.value, {
content,
created: Date.now(),
id: userMessageId,
});
return userMessageId;
}
function removeMessageById(messageId: string) {
const index = messageList.value.findIndex((item) => item.id === messageId);
if (index >= 0) {
messageList.value.splice(index, 1);
}
}
async function sendMessage(prompt?: string) {
await sendChatRequest({
prompt,
});
}
async function sendChatRequest(options: {
prompt?: string;
regenerateRoundId?: string;
}) {
const content = (options.prompt || senderValue.value).trim();
if (!content || !currentAssistantId.value || composerLocked.value) {
return;
}
const regenerateRoundId = options.regenerateRoundId
? String(options.regenerateRoundId)
: '';
const isRegenerate = !!regenerateRoundId;
if (!isRegenerate && latestAssistantMessage.value?.roundId) {
ChatTimeTimelineBuilder.setRoundSwitchable(
messageList.value,
latestAssistantMessage.value.roundId,
false,
);
}
const hadSessionBeforeSend = !!currentSession.value?.sessionId;
let conversationId = '';
try {
conversationId = await ensureConversationSession();
} catch (error: any) {
ElMessage.error(error?.message || '暂时无法创建会话,请稍后再试');
return;
}
if (!isRegenerate) {
optimisticSessionIds.value = new Set([
...optimisticSessionIds.value,
conversationId,
]);
upsertSessionRecord(createOptimisticSessionRecord(conversationId, content), {
incrementTotal: !hadSessionBeforeSend,
});
currentSession.value = {
...currentSession.value,
accessAt: new Date().toISOString(),
assistantId: currentAssistantId.value,
assistantName: activeAssistantName.value || composerAssistantName.value,
continuable: true,
lastMessageAt: new Date().toISOString(),
lastMessagePreview: content,
messageCount: Math.max(Number(currentSession.value?.messageCount || 0), 0) + 1,
readOnlyReason: null,
sessionId: conversationId,
title: currentSession.value?.title || '新对话',
};
await syncSessionRoute(conversationId);
}
sending.value = true;
activeStreamSessionId.value = conversationId;
const userMessageId = isRegenerate ? '' : appendUserMessage(content);
let receivedAssistantPayload = false;
let streamSettled = false;
if (!options.prompt) {
senderValue.value = '';
}
scrollToBottom(true);
await sseClient.post(
'/api/v1/bot/chat',
{
botId: currentAssistantId.value,
conversationId,
extraKnowledgeIds: extraKnowledgeIds.value,
prompt: content,
publishedOnly: true,
regenerateRoundId: regenerateRoundId || undefined,
},
{
onMessage(message) {
if (activeStreamSessionId.value !== conversationId) {
return;
}
if (message.event === 'done') {
streamSettled = true;
sending.value = false;
ChatTimeTimelineBuilder.finalize(messageList.value);
activeStreamSessionId.value = '';
refreshAfterSend(conversationId);
return;
}
if (!message.data) {
return;
}
const sseData = JSON.parse(message.data);
const streamMeta = normalizeSseRoundMeta(sseData?.meta);
ChatTimeTimelineBuilder.bindLatestPendingUserMessage(
messageList.value,
streamMeta,
);
const delta = sseData.payload?.delta;
if (
sseData?.domain === 'SYSTEM' &&
sseData.payload?.code === 'SYSTEM_ERROR'
) {
if (!isRegenerate && userMessageId) {
removeMessageById(userMessageId);
}
if (!options.prompt && !isRegenerate) {
senderValue.value = content;
}
sending.value = false;
if (isRegenerate && !receivedAssistantPayload) {
ElMessage.error(sseData.payload.message || '重新生成失败');
} else {
ChatTimeTimelineBuilder.applySystemError(
messageList.value,
sseData.payload.message,
Date.now(),
);
}
return;
}
if (sseData?.domain === 'TOOL') {
receivedAssistantPayload = true;
if (sseData?.type === 'TOOL_CALL') {
ChatTimeTimelineBuilder.upsertToolCall(messageList.value, {
created: Date.now(),
...streamMeta,
name: sseData?.payload?.name,
toolCallId: sseData?.payload?.tool_call_id,
value: sseData?.payload?.arguments,
});
} else {
ChatTimeTimelineBuilder.upsertToolResult(messageList.value, {
created: Date.now(),
...streamMeta,
name: sseData?.payload?.name,
result: sseData?.payload?.result,
toolCallId: sseData?.payload?.tool_call_id,
});
}
scrollToBottom();
return;
}
if (sseData.type === 'THINKING') {
receivedAssistantPayload = true;
ChatTimeTimelineBuilder.appendThinkingDelta(
messageList.value,
delta,
Date.now(),
streamMeta,
);
} else if (sseData.type === 'MESSAGE') {
receivedAssistantPayload = true;
ChatTimeTimelineBuilder.appendMessageDelta(
messageList.value,
delta,
Date.now(),
streamMeta,
);
}
scrollToBottom();
},
async onError(error) {
if (activeStreamSessionId.value !== conversationId) {
return;
}
console.error(error);
if (!receivedAssistantPayload) {
if (!isRegenerate && userMessageId) {
removeMessageById(userMessageId);
}
if (!options.prompt && !isRegenerate) {
senderValue.value = content;
}
}
const errorMessage = String(error?.message || '').trim();
if (errorMessage && errorMessage !== 'This operation was aborted') {
ElMessage.error(errorMessage);
}
sending.value = false;
ChatTimeTimelineBuilder.finalize(messageList.value);
await refreshAfterSend(conversationId);
activeStreamSessionId.value = '';
},
async onFinished() {
if (activeStreamSessionId.value !== conversationId) {
return;
}
sending.value = false;
ChatTimeTimelineBuilder.finalize(messageList.value);
if (streamSettled) {
return;
}
await refreshAfterSend(conversationId);
activeStreamSessionId.value = '';
},
},
);
}
async function refreshAfterSend(sessionId?: string) {
const currentId = String(sessionId || currentSession.value?.sessionId || '');
if (!currentId) {
return;
}
for (const delay of [0, 240, 480]) {
if (delay > 0) {
await sleep(delay);
}
const detail = await requestSessionDetail(currentId);
if (!detail) {
continue;
}
applySessionDetail(
{
...detail,
title: detail.title || currentSession.value?.title || '新对话',
},
{ resetSender: false },
);
upsertSessionRecord(
{
accessAt: detail.accessAt,
assistantCode: detail.assistantCode,
assistantId: String(detail.assistantId || currentAssistantId.value || ''),
assistantName: detail.assistantName,
continuable: detail.continuable,
lastMessageAt: detail.lastMessageAt,
lastMessagePreview: detail.lastMessagePreview,
messageCount: detail.messageCount,
readOnlyReason: detail.readOnlyReason,
sessionId: currentId,
title: detail.title || currentSession.value?.title || '新对话',
},
);
const conversation = await requestConversation(currentId);
if (!conversation) {
continue;
}
applyConversation(conversation, currentId);
await syncSessionRoute(currentId);
scrollToBottom();
return;
}
}
function sleep(delay: number) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
function stopSending() {
abortActiveStream();
}
function abortActiveStream() {
const shouldFinalize = sending.value || sseClient.isActive();
activeStreamSessionId.value = '';
sseClient.abort();
sending.value = false;
if (shouldFinalize) {
ChatTimeTimelineBuilder.finalize(messageList.value);
}
}
function canCopyMessage(item: ChatTimeTimelineItem) {
if (item.role === 'tool') {
return false;
}
if (item.role === 'assistant' && !isFinalAssistantInRound(item)) {
return false;
}
return !!String(item.content || '').trim();
}
function canRegenerateMessage(item: ChatTimeTimelineItem) {
if (item.role !== 'assistant') {
return false;
}
if (isReadOnly.value || isWelcomeComposerState.value || sending.value) {
return false;
}
if (!isFinalAssistantInRound(item)) {
return false;
}
if (item.id !== latestAssistantMessageId.value) {
return false;
}
const roundId = String(item.roundId || '');
return !!roundId && !!resolveRoundUserPrompt(roundId);
}
async function handleCopyMessage(item: ChatTimeTimelineItem) {
if (!canCopyMessage(item)) {
return;
}
await copyMessageContent(item.content);
}
async function handleRegenerateMessage() {
if (sending.value || isReadOnly.value) {
return;
}
const roundId = String(latestAssistantMessage.value?.roundId || '');
const prompt = resolveRoundUserPrompt(roundId);
if (!prompt) {
ElMessage.warning('暂无可重新生成的问题');
return;
}
if (!roundId) {
ElMessage.warning('当前回答暂不支持重新生成');
return;
}
await sendChatRequest({
prompt,
regenerateRoundId: roundId,
});
}
function shouldShowVariantNavigator(item: ChatTimeTimelineItem) {
return (
item.role === 'assistant' &&
isFinalAssistantInRound(item) &&
Number(item.variantCount || 0) > 1 &&
item.switchable === true &&
!!item.roundId
);
}
function canSwitchVariant(item: ChatTimeTimelineItem, direction: 'next' | 'previous') {
if (item.role !== 'assistant') {
return false;
}
if (!isFinalAssistantInRound(item)) {
return false;
}
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
const total = Number(item.variantCount || 1);
if (!item.switchable) {
return false;
}
if (isVariantSwitching(item)) {
return false;
}
if (direction === 'previous') {
return current > 1;
}
return current < total;
}
function isVariantSwitching(item: ChatTimeTimelineItem) {
variantSwitchStateVersion.value;
return variantSwitchController.isSwitching(
currentSession.value?.sessionId,
item.roundId,
);
}
function isFinalAssistantInRound(item: ChatTimeTimelineItem) {
if (item.role !== 'assistant') {
return false;
}
if (!item.roundId) {
return true;
}
for (let index = messageList.value.length - 1; index >= 0; index -= 1) {
const candidate = messageList.value[index];
if (
candidate?.role === 'assistant' &&
candidate.roundId === item.roundId &&
String(candidate.variantIndex || '') === String(item.variantIndex || '')
) {
return candidate.id === item.id;
}
}
return true;
}
async function selectVariantForMessage(
item: ChatTimeTimelineItem,
direction: 'next' | 'previous',
) {
if (item.role !== 'assistant' || !item.roundId || !currentSession.value?.sessionId) {
return;
}
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
const nextVariantIndex = direction === 'previous' ? current - 1 : current + 1;
if (!canSwitchVariant(item, direction)) {
return;
}
const sessionId = String(currentSession.value.sessionId);
const roundId = String(item.roundId);
const previousPreview = currentSession.value?.lastMessagePreview;
const selected = await variantSwitchController.switchVariant({
fetchVariants: async () => {
const [, res] = await tryit(api.get)(
`/api/v1/chatWorkspace/sessions/${sessionId}/rounds/${roundId}/variants`,
);
if (res?.errorCode !== 0 || !Array.isArray(res?.data)) {
throw new Error('答案版本加载失败');
}
return res.data;
},
items: messageList.value,
onLocalSwitch(record) {
const preview = String(record.contentText || record.content || '').trim();
if (currentSession.value) {
currentSession.value.lastMessagePreview = preview;
}
},
persistVariant: async () => {
const [, res] = await tryit(api.post)(
`/api/v1/chatWorkspace/sessions/${currentSession.value.sessionId}/rounds/${item.roundId}/selectVariant`,
{
variantIndex: nextVariantIndex,
},
);
if (res?.errorCode !== 0 || !res?.data) {
throw new Error(res?.message || '答案版本切换失败');
}
return res.data;
},
roundId,
sessionId,
targetVariantIndex: nextVariantIndex,
});
if (!selected && currentSession.value) {
currentSession.value.lastMessagePreview = previousPreview;
}
}
function normalizeSseRoundMeta(meta: any): SseRoundMeta {
if (!meta || typeof meta !== 'object') {
return {};
}
const variantIndex = meta.variantIndex ? Number(meta.variantIndex) : undefined;
return {
regenerate: Boolean(meta.regenerate),
regenerateRoundId: meta.regenerateRoundId
? String(meta.regenerateRoundId)
: undefined,
roundId: meta.roundId ? String(meta.roundId) : undefined,
roundNo: meta.roundNo ? Number(meta.roundNo) : undefined,
selectedVariantIndex: meta.selectedVariantIndex
? Number(meta.selectedVariantIndex)
: variantIndex,
switchable:
typeof meta.switchable === 'boolean'
? meta.switchable
: variantIndex != null
? true
: undefined,
variantCount: meta.variantCount ? Number(meta.variantCount) : variantIndex,
variantIndex,
};
}
function handleExtraKnowledgeIdsChange(value: string[]) {
extraKnowledgeIds.value = value.map((item) => String(item));
}
function handleEnter(event: Event | KeyboardEvent) {
if (event instanceof KeyboardEvent && event.shiftKey) {
return;
}
event.preventDefault();
sendMessage();
}
async function deleteSession(targetSession?: SessionItem) {
const session = targetSession || sessionContextMenuTarget.value || currentSession.value;
if (!session?.sessionId) {
return;
}
closeSessionContextMenu();
const [, confirmRes] = await tryit(ElMessageBox.confirm)(
'删除后该会话将不再出现在历史列表中,是否继续?',
'删除会话',
{
cancelButtonText: '取消',
confirmButtonText: '删除',
type: 'warning',
},
);
if (!confirmRes) {
return;
}
const [, res] = await tryit(api.post)(
`/api/v1/chatWorkspace/sessions/${session.sessionId}/delete`,
{},
);
if (res?.errorCode === 0) {
ElMessage.success('删除成功');
removeSessionRecord(String(session.sessionId));
if (String(currentSession.value?.sessionId || '') === String(session.sessionId)) {
resetComposerState();
await router.replace({ query: {} });
}
}
}
</script>
<template>
<div class="chat-workspace">
<aside class="chat-workspace__sidebar">
<div class="chat-workspace__sidebar-header">
<ElButton
class="chat-workspace__new-session"
:icon="Plus"
:disabled="!canCreateSession"
@click="handleCreateSession"
>
新建会话
</ElButton>
<ElInput
v-model="sessionSearchKeyword"
class="chat-workspace__session-search"
clearable
:prefix-icon="Search"
placeholder="搜索会话"
/>
</div>
<ElScrollbar
class="chat-workspace__sidebar-scroll"
@scroll="handleSidebarScroll"
>
<div class="chat-workspace__sidebar-body">
<div class="chat-workspace__session-list-head">
<span class="chat-workspace__panel-caption">会话</span>
</div>
<div
v-if="groupedSessions.length > 0"
class="chat-workspace__session-groups"
>
<section
v-for="group in groupedSessions"
:key="group.key"
class="chat-workspace__session-group"
>
<h2 class="chat-workspace__group-title">{{ group.title }}</h2>
<div
v-for="session in group.items"
:key="session.sessionId"
role="button"
tabindex="0"
class="chat-workspace__session-item"
:class="{
'is-active': selectedSessionKey === String(session.sessionId),
'is-context-open':
sessionContextMenuVisible &&
sessionContextMenuTarget?.sessionId === session.sessionId,
'is-readonly': session.continuable === false,
}"
@click="openSession(session)"
@contextmenu="openSessionContextMenu($event, session)"
@keydown="handleSessionItemKeydown($event, session)"
>
<div class="chat-workspace__session-main">
<div class="chat-workspace__session-row">
<span class="chat-workspace__session-name">
{{ resolveSessionLabel(session) }}
</span>
<span class="chat-workspace__session-time">
{{ resolveSessionTimeLabel(session) }}
</span>
</div>
<div class="chat-workspace__session-row is-sub">
<span class="chat-workspace__session-subtitle">
{{ resolveSessionSubtitle(session) }}
</span>
<ElTag
v-if="session.continuable === false"
size="small"
effect="plain"
type="info"
>
只读
</ElTag>
</div>
</div>
</div>
</section>
<div
v-if="loadingMoreSessions || canLoadMoreSessions"
class="chat-workspace__session-loadmore"
>
<span v-if="loadingMoreSessions">加载更多会话中...</span>
<span v-else>向下滚动加载更多</span>
</div>
</div>
<ElEmpty
v-else-if="loadingSessions"
description="会话加载中"
class="chat-workspace__empty"
/>
<ElEmpty
v-else-if="sessionSearchKeyword"
description="未找到匹配会话"
class="chat-workspace__empty"
/>
<ElEmpty
v-else
description="当前用户暂无会话"
class="chat-workspace__empty"
/>
</div>
</ElScrollbar>
<div
v-if="sessionContextMenuVisible && sessionContextMenuTarget"
class="chat-workspace__context-menu"
:style="{
left: `${sessionContextMenuX}px`,
top: `${sessionContextMenuY}px`,
}"
@click.stop
>
<button
type="button"
class="chat-workspace__context-menu-item is-danger"
@click="deleteSession(sessionContextMenuTarget)"
>
<Delete class="chat-workspace__context-menu-icon" />
<span>删除</span>
</button>
</div>
</aside>
<main class="chat-workspace__main">
<header class="chat-workspace__topbar">
<div class="chat-workspace__topbar-content">
<div class="chat-workspace__topbar-main">
<h1 class="chat-workspace__topbar-title">
{{ currentSessionTitle }}
</h1>
<div class="chat-workspace__topbar-meta">
<span>{{ activeAssistantName || '未选择智能体' }}</span>
<ElTag
v-if="isReadOnly"
type="info"
effect="plain"
size="small"
>
只读
</ElTag>
</div>
<p
v-if="isReadOnly"
class="chat-workspace__readonly-tip"
>
{{ resolveReadOnlyText(currentSession?.readOnlyReason) }}
</p>
</div>
</div>
</header>
<section
class="chat-workspace__conversation"
:class="{ 'is-welcome-layout': isWelcomeComposerState }"
>
<div v-if="isWelcomeComposerState" class="chat-workspace__welcome-stage">
<div class="chat-workspace__conversation-content">
<div class="chat-workspace__welcome is-centered">
<div class="chat-workspace__welcome-title chat-workspace__welcome-title--inline">
<span>{{ greetingPrefix }}</span>
<ChatWelcomeAssistantPicker
:assistant-id="currentAssistantId"
:assistant-name="activeAssistantName || composerAssistantName"
:assistant-options="assistants"
:disabled="sending"
:loading="loadingAssistants"
@update:assistant-id="handleAssistantChange"
/>
<span>一起协作</span>
</div>
</div>
</div>
</div>
<ElScrollbar
v-else
class="chat-workspace__message-scroll"
@scroll="handleMessageScroll"
>
<div class="chat-workspace__message-shell">
<div class="chat-workspace__conversation-content">
<div
v-if="hasMessages"
class="chat-workspace__message-list"
>
<article
v-for="item in messageList"
:key="item.id"
class="chat-workspace__message"
:class="`is-${item.role}`"
>
<div class="chat-workspace__message-stack" :class="`is-${item.role}`">
<div class="chat-workspace__message-bubble" :class="`is-${item.role}`">
<ChatTimeMessageContent :item="item" />
</div>
<ChatMessageActionBar
:align="item.role === 'user' ? 'end' : 'start'"
:allow-copy="canCopyMessage(item)"
:allow-regenerate="canRegenerateMessage(item)"
:disabled-variant-next="!canSwitchVariant(item, 'next')"
:disabled-variant-previous="!canSwitchVariant(item, 'previous')"
:regenerate-disabled="sending || isReadOnly"
:show-variant-navigator="shouldShowVariantNavigator(item)"
:variant-loading="false"
:variant-current="
Number(item.variantIndex || item.selectedVariantIndex || 1)
"
:variant-total="Number(item.variantCount || 1)"
@copy="handleCopyMessage(item)"
@regenerate="handleRegenerateMessage"
@select-next-variant="selectVariantForMessage(item, 'next')"
@select-previous-variant="
selectVariantForMessage(item, 'previous')
"
/>
</div>
</article>
</div>
<div v-else class="chat-workspace__welcome">
<div class="chat-workspace__welcome-badge">聊天</div>
<h2 class="chat-workspace__welcome-title">
{{ emptyConversationTitle }}
</h2>
<p class="chat-workspace__welcome-text">
{{ emptyConversationText }}
</p>
</div>
</div>
</div>
</ElScrollbar>
<footer
class="chat-workspace__composer-shell"
:class="{ 'is-centered': isWelcomeComposerState }"
>
<div class="chat-workspace__conversation-content">
<div
class="chat-workspace__composer"
:class="{ 'is-disabled': composerLocked }"
>
<ElInput
v-model="senderValue"
class="chat-workspace__textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
:disabled="composerLocked"
resize="none"
type="textarea"
:placeholder="composerPlaceholder"
@keydown.enter="handleEnter"
/>
<div class="chat-workspace__composer-footer">
<div class="chat-workspace__composer-actions">
<ElButton
v-if="sending"
aria-label="停止"
class="chat-workspace__send-button is-stop"
@click="stopSending"
>
<span class="chat-workspace__stop-glyph" />
</ElButton>
<ElButton
v-else
:icon="Promotion"
aria-label="发送"
class="chat-workspace__send-button"
:disabled="!canSend"
@click="sendMessage()"
/>
</div>
</div>
</div>
<ChatContextCapsuleBar
class="chat-workspace__context-rail-shell"
:bound-knowledges="displayedBoundKnowledges"
:selected-extra-knowledges="displayedExtraKnowledges"
:extra-knowledge-ids="
isReadOnly ? displayedExtraKnowledgeIds : editableKnowledgeIds
"
:extra-knowledge-options="extraKnowledgeOptionList"
:disabled="contextSelectorDisabled"
:knowledge-disabled="!currentAssistantId || composerLocked"
:loading="loadingKnowledgeOptions"
:mode="isReadOnly ? 'readonly' : 'editable'"
@update:extra-knowledge-ids="handleExtraKnowledgeIdsChange"
/>
<div
v-if="isReadOnly"
class="chat-workspace__composer-disabled"
>
{{ resolveReadOnlyText(currentSession?.readOnlyReason) }}
</div>
</div>
</footer>
</section>
</main>
</div>
</template>
<style scoped>
.chat-workspace {
--chat-content-width: 840px;
--chat-sidebar-width: 272px;
display: grid;
grid-template-columns: var(--chat-sidebar-width) minmax(0, 1fr);
gap: 14px;
height: calc(100vh - 132px);
min-height: 640px;
padding: 16px;
overflow: hidden;
background:
radial-gradient(circle at top left, hsl(var(--nav-ambient) / 0.08), transparent 20%),
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background-deep)) 100%);
}
.chat-workspace__sidebar,
.chat-workspace__main {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
border: 1px solid hsl(var(--divider-faint) / 0.7);
border-radius: 18px;
background: hsl(var(--surface-panel) / 0.94);
box-shadow: 0 10px 28px -26px hsl(var(--foreground) / 0.18);
}
.chat-workspace__sidebar-header {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 14px 10px;
border-bottom: 1px solid hsl(var(--divider-faint) / 0.64);
}
.chat-workspace__new-session {
justify-content: flex-start;
width: 100%;
height: 36px;
padding-inline: 12px;
font-size: 13px;
font-weight: 600;
color: hsl(var(--text-strong));
border-color: hsl(var(--divider-faint) / 0.86);
border-radius: 12px;
background: hsl(var(--surface-subtle));
box-shadow: none;
}
.chat-workspace__new-session:hover:not(.is-disabled) {
background: hsl(var(--surface-panel));
}
.chat-workspace__new-session:focus-visible,
.chat-workspace__session-item:focus-visible,
.chat-workspace__session-more:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.32);
outline-offset: 2px;
}
.chat-workspace__session-search :deep(.el-input__wrapper) {
min-height: 36px;
padding-inline: 12px;
border-radius: 12px;
border: 1px solid transparent;
box-shadow: none;
background: hsl(var(--surface-subtle));
}
.chat-workspace__session-search :deep(.el-input__wrapper.is-focus) {
border-color: hsl(var(--primary) / 0.18);
}
.chat-workspace__sidebar-scroll,
.chat-workspace__message-scroll {
flex: 1;
min-height: 0;
}
.chat-workspace__sidebar-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px 10px 14px;
}
.chat-workspace__panel-caption {
font-size: 11px;
font-weight: 600;
color: hsl(var(--text-muted));
letter-spacing: 0.02em;
}
.chat-workspace__session-list-head {
padding-inline: 4px;
}
.chat-workspace__session-groups {
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-workspace__session-loadmore {
display: flex;
justify-content: center;
padding: 8px 4px 2px;
font-size: 11px;
color: hsl(var(--text-muted));
}
.chat-workspace__session-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.chat-workspace__group-title {
margin: 0;
padding: 0 4px;
font-size: 11px;
font-weight: 600;
color: hsl(var(--text-muted));
letter-spacing: 0.02em;
}
.chat-workspace__session-item {
position: relative;
display: flex;
gap: 8px;
align-items: flex-start;
justify-content: space-between;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
transition:
border-color 0.18s ease,
background-color 0.18s ease;
}
.chat-workspace__session-item:hover {
background: hsl(var(--surface-subtle));
}
.chat-workspace__session-item.is-active {
border-color: hsl(var(--divider-faint) / 0.92);
background: hsl(var(--surface-subtle) / 0.96);
}
.chat-workspace__session-item.is-context-open {
background: hsl(var(--surface-subtle));
}
.chat-workspace__session-item.is-readonly {
color: hsl(var(--text-muted));
}
.chat-workspace__session-main {
flex: 1;
min-width: 0;
}
.chat-workspace__session-row {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.chat-workspace__session-row.is-sub {
margin-top: 2px;
}
.chat-workspace__session-name,
.chat-workspace__session-subtitle {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-workspace__session-name {
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.chat-workspace__session-subtitle,
.chat-workspace__session-time {
font-size: 11px;
color: hsl(var(--text-muted));
}
.chat-workspace__context-menu {
position: fixed;
z-index: 40;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 144px;
padding: 6px;
border: 1px solid hsl(var(--divider-faint) / 0.84);
border-radius: 14px;
background: hsl(var(--surface-panel) / 0.98);
box-shadow: 0 16px 36px -24px hsl(var(--foreground) / 0.28);
backdrop-filter: blur(12px);
}
.chat-workspace__context-menu-item {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
min-height: 32px;
padding: 0 10px;
font-size: 12px;
font-weight: 500;
color: hsl(var(--text-strong));
text-align: left;
border: none;
border-radius: 10px;
background: transparent;
transition: background-color 0.18s ease;
}
.chat-workspace__context-menu-item:hover {
background: hsl(var(--surface-subtle));
}
.chat-workspace__context-menu-item.is-danger {
color: hsl(var(--destructive));
}
.chat-workspace__context-menu-item.is-danger:hover {
background: hsl(var(--destructive) / 0.08);
}
.chat-workspace__context-menu-icon {
flex: none;
width: 14px;
height: 14px;
}
.chat-workspace__empty {
padding-top: 40px;
}
.chat-workspace__topbar {
flex: none;
padding: 14px 24px 10px;
border-bottom: 1px solid hsl(var(--divider-faint) / 0.64);
}
.chat-workspace__topbar-content {
max-width: var(--chat-content-width);
margin: 0 auto;
}
.chat-workspace__topbar-main {
min-width: 0;
}
.chat-workspace__topbar-title {
margin: 0;
font-size: 17px;
font-weight: 650;
color: hsl(var(--text-strong));
letter-spacing: -0.01em;
}
.chat-workspace__topbar-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 4px;
font-size: 12px;
color: hsl(var(--text-muted));
}
.chat-workspace__readonly-tip {
margin: 6px 0 0;
font-size: 11px;
color: hsl(var(--text-muted));
}
.chat-workspace__conversation {
position: relative;
flex: 1;
min-height: 0;
}
.chat-workspace__conversation.is-welcome-layout {
display: flex;
flex-direction: column;
justify-content: center;
padding: 32px 24px;
}
.chat-workspace__welcome-stage {
flex: none;
}
.chat-workspace__message-shell {
min-height: 100%;
padding: 0 24px 196px;
}
.chat-workspace__conversation-content {
width: 100%;
max-width: var(--chat-content-width);
margin: 0 auto;
}
.chat-workspace__more {
display: flex;
justify-content: center;
padding-top: 20px;
}
.chat-workspace__message-list {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px 0 36px;
}
.chat-workspace__message {
display: flex;
width: 100%;
}
.chat-workspace__message.is-user {
justify-content: flex-end;
}
.chat-workspace__message-stack {
display: flex;
flex-direction: column;
min-width: 0;
max-width: 100%;
}
.chat-workspace__message-stack.is-user {
align-items: flex-end;
width: 100%;
}
.chat-workspace__message-bubble {
min-width: 0;
}
.chat-workspace__message-bubble.is-assistant {
width: 100%;
max-width: 100%;
padding: 0;
color: hsl(var(--text-strong));
border: none;
border-radius: 0;
background: transparent;
}
.chat-workspace__message-bubble.is-user {
display: block;
align-self: flex-end;
width: fit-content;
max-width: min(72%, 720px);
min-width: 0;
padding: 0;
text-align: left;
color: hsl(var(--text-strong));
white-space: pre-wrap;
border: none;
border-radius: 0;
background: transparent;
overflow-wrap: anywhere;
}
.chat-workspace__message-bubble.is-tool {
width: 100%;
max-width: min(100%, 720px);
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
.chat-workspace__message-bubble.is-user :deep(.chat-time-markdown),
.chat-workspace__message-bubble.is-user :deep(.elx-xmarkdown-container),
.chat-workspace__message-bubble.is-user :deep(.elx-xmarkdown-provider) {
width: auto;
max-width: 100%;
}
.chat-workspace__message-bubble.is-assistant :deep(pre),
.chat-workspace__message-bubble.is-user :deep(pre),
.chat-workspace__message-bubble.is-tool :deep(pre) {
overflow: auto;
border-radius: 14px;
}
.chat-workspace__message-bubble.is-tool :deep(.chat-tool-panel) {
border-color: hsl(var(--divider-faint) / 0.82);
border-radius: 12px;
background: hsl(var(--surface-panel));
}
.chat-workspace__welcome {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
min-height: 100%;
padding: 36px 0 72px;
}
.chat-workspace__welcome-badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
font-size: 11px;
font-weight: 600;
color: hsl(var(--text-muted));
border: 1px solid hsl(var(--divider-faint) / 0.88);
border-radius: 999px;
background: hsl(var(--surface-subtle));
}
.chat-workspace__welcome-title {
max-width: 480px;
margin: 14px 0 0;
font-size: 26px;
font-weight: 680;
line-height: 1.18;
color: hsl(var(--text-strong));
letter-spacing: -0.03em;
}
.chat-workspace__welcome-title--inline {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: center;
max-width: 720px;
line-height: 1.3;
}
.chat-workspace__welcome-text {
max-width: 440px;
margin: 8px 0 0;
font-size: 13px;
line-height: 1.65;
color: hsl(var(--text-muted));
}
.chat-workspace__welcome.is-centered {
align-items: center;
justify-content: flex-end;
min-height: 0;
padding: 0;
text-align: center;
}
.chat-workspace__welcome.is-centered .chat-workspace__welcome-title,
.chat-workspace__welcome.is-centered .chat-workspace__welcome-text {
max-width: 720px;
}
.chat-workspace__welcome.is-centered .chat-workspace__welcome-title {
margin-top: 0;
}
.chat-workspace__composer-shell {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 0 24px 20px;
background:
linear-gradient(180deg, hsl(var(--surface-panel) / 0) 0%, hsl(var(--surface-panel) / 0.78) 30%, hsl(var(--surface-panel) / 0.96) 62%, hsl(var(--surface-panel)) 100%);
}
.chat-workspace__composer-shell.is-centered {
position: static;
padding: 28px 0 0;
background: transparent;
}
.chat-workspace__composer {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px 10px;
border: 1px solid hsl(var(--divider-faint) / 0.82);
border-radius: 24px;
background: hsl(var(--surface-panel) / 0.985);
box-shadow: 0 24px 44px -36px hsl(var(--foreground) / 0.18);
}
.chat-workspace__composer.is-disabled {
opacity: 0.74;
}
.chat-workspace__textarea :deep(.el-textarea__wrapper) {
padding: 0;
border: none;
border-radius: 0;
box-shadow: none;
background: transparent;
}
.chat-workspace__textarea :deep(.el-textarea__inner) {
padding: 0;
min-height: 52px !important;
border: none;
outline: none;
box-shadow: none;
font-size: 15px;
line-height: 1.58;
color: hsl(var(--text-strong));
background: transparent;
resize: none;
}
.chat-workspace__textarea :deep(.el-textarea__inner::placeholder) {
color: hsl(var(--text-muted));
}
.chat-workspace__composer-footer {
display: flex;
gap: 12px;
align-items: center;
justify-content: flex-end;
min-height: 42px;
}
.chat-workspace__composer-actions {
display: flex;
gap: 8px;
align-items: center;
flex: none;
}
.chat-workspace__context-rail-shell {
margin-top: 10px;
}
.chat-workspace__send-button {
width: 42px;
min-width: 42px;
height: 42px;
padding: 0;
color: hsl(var(--background));
border: none;
border-radius: 999px;
background: hsl(var(--foreground));
box-shadow: none;
}
.chat-workspace__send-button:hover:not(.is-disabled) {
background: hsl(var(--foreground) / 0.88);
}
.chat-workspace__send-button :deep(.el-icon) {
font-size: 15px;
}
.chat-workspace__send-button.is-stop {
color: hsl(var(--background));
border: none;
background: hsl(var(--foreground));
}
.chat-workspace__send-button.is-stop:hover:not(.is-disabled) {
background: hsl(var(--foreground) / 0.88);
}
.chat-workspace__stop-glyph {
display: block;
width: 12px;
height: 12px;
border-radius: 3px;
background: currentColor;
}
.chat-workspace__composer-disabled {
margin-top: 8px;
font-size: 12px;
color: hsl(var(--text-muted));
}
@media (max-width: 1180px) {
.chat-workspace {
--chat-sidebar-width: 252px;
}
}
@media (max-width: 1024px) {
.chat-workspace {
grid-template-columns: 1fr;
height: auto;
min-height: 0;
overflow: visible;
}
.chat-workspace__sidebar {
max-height: 460px;
}
.chat-workspace__conversation {
min-height: 720px;
}
.chat-workspace__conversation.is-welcome-layout {
padding-inline: 16px;
}
}
@media (max-width: 768px) {
.chat-workspace {
padding: 12px;
}
.chat-workspace__topbar,
.chat-workspace__message-shell,
.chat-workspace__composer-shell {
padding-inline: 16px;
}
.chat-workspace__message-shell {
padding-bottom: 236px;
}
.chat-workspace__welcome-title {
font-size: 24px;
}
.chat-workspace__welcome-title--inline {
gap: 8px;
}
.chat-workspace__conversation.is-welcome-layout {
padding: 24px 16px;
}
.chat-workspace__composer-footer {
justify-content: flex-end;
}
.chat-workspace__message-bubble.is-user {
max-width: 86%;
}
}
</style>