2559 lines
70 KiB
Vue
2559 lines
70 KiB
Vue
<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>
|