1433 lines
37 KiB
Vue
1433 lines
37 KiB
Vue
<script setup lang="ts">
|
||
import type {BotInfo} from '@easyflow/types';
|
||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
||
|
||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue';
|
||
import {useRoute} from 'vue-router';
|
||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||
|
||
import {IconifyIcon} from '@easyflow/icons';
|
||
import {$t} from '@easyflow/locales';
|
||
import {useAccessStore} from '@easyflow/stores';
|
||
import {uuid} from '@easyflow/utils';
|
||
import {useTitle} from '@vueuse/core';
|
||
|
||
import {ElAvatar, ElButton, ElInput, ElSkeleton} from 'element-plus';
|
||
|
||
import {api, sseClient} from '#/api/request';
|
||
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
||
|
||
type MessageRole = 'assistant' | 'user';
|
||
type ToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
|
||
|
||
interface ToolTraceItem {
|
||
id: string;
|
||
name: string;
|
||
status: ToolStatus;
|
||
arguments?: string;
|
||
result?: string;
|
||
}
|
||
|
||
interface AssistantTextSegment {
|
||
id: string;
|
||
type: 'text';
|
||
content: string;
|
||
}
|
||
|
||
interface AssistantToolSegment {
|
||
id: string;
|
||
type: 'tool';
|
||
toolId: string;
|
||
}
|
||
|
||
type AssistantSegment = AssistantTextSegment | AssistantToolSegment;
|
||
|
||
interface BubbleMessage {
|
||
id: string;
|
||
role: MessageRole;
|
||
content: string;
|
||
createdAt: number;
|
||
loading?: boolean;
|
||
toolCalls?: ToolTraceItem[];
|
||
segments?: AssistantSegment[];
|
||
}
|
||
|
||
interface HistoryMessage {
|
||
content: string;
|
||
role: string;
|
||
}
|
||
|
||
const route = useRoute();
|
||
const accessStore = useAccessStore();
|
||
const pageTitle = useTitle();
|
||
|
||
const botInfo = ref<BotInfo | null>(null);
|
||
const conversationId = ref<string>('');
|
||
const initializing = ref(true);
|
||
const blocked = ref(false);
|
||
const initError = ref('');
|
||
const sending = ref(false);
|
||
const userStoppedStreaming = ref(false);
|
||
const isComposing = ref(false);
|
||
const senderValue = ref('');
|
||
const messages = ref<BubbleMessage[]>([]);
|
||
const historyMessages = ref<HistoryMessage[]>([]);
|
||
const messageContainerRef = ref<HTMLElement | null>(null);
|
||
const dockRef = ref<HTMLElement | null>(null);
|
||
const shouldAutoScroll = ref(true);
|
||
const dockHeight = ref(170);
|
||
const collapsedToolIds = ref<Set<string>>(new Set());
|
||
const botId = computed(() => String(route.params.botId || ''));
|
||
const isEmbedMode = computed(() => String(route.query.embed || '') === '1');
|
||
const presetQuestions = computed(() =>
|
||
(botInfo.value?.options?.presetQuestions || []).filter(
|
||
(item: any) => typeof item?.description === 'string' && item.description.trim() !== '',
|
||
),
|
||
);
|
||
const messageContainerStyle = computed(() => ({
|
||
'--public-chat-dock-space': `${Math.max(dockHeight.value + 14, 132)}px`,
|
||
}));
|
||
let dockResizeObserver: null | ResizeObserver = null;
|
||
|
||
const SCROLL_FOLLOW_THRESHOLD = 48;
|
||
const setPageTitle = () => {
|
||
const botTitle = String(botInfo.value?.title || '').trim();
|
||
pageTitle.value = botTitle || $t('bot.publicChatTitle');
|
||
};
|
||
|
||
const isAtBottom = () => {
|
||
if (!messageContainerRef.value) return true;
|
||
const { scrollTop, scrollHeight, clientHeight } = messageContainerRef.value;
|
||
return scrollHeight - (scrollTop + clientHeight) <= SCROLL_FOLLOW_THRESHOLD;
|
||
};
|
||
|
||
const onMessageContainerScroll = () => {
|
||
shouldAutoScroll.value = isAtBottom();
|
||
};
|
||
|
||
const updateDockHeight = () => {
|
||
dockHeight.value = dockRef.value?.offsetHeight || 0;
|
||
};
|
||
|
||
const scrollToBottom = (force = false) => {
|
||
nextTick(() => {
|
||
if (!messageContainerRef.value) return;
|
||
if (!force && !shouldAutoScroll.value) return;
|
||
messageContainerRef.value.scrollTop = messageContainerRef.value.scrollHeight;
|
||
});
|
||
};
|
||
|
||
const resetConversationState = () => {
|
||
botInfo.value = null;
|
||
conversationId.value = '';
|
||
sending.value = false;
|
||
senderValue.value = '';
|
||
messages.value = [];
|
||
historyMessages.value = [];
|
||
blocked.value = false;
|
||
initError.value = '';
|
||
shouldAutoScroll.value = true;
|
||
collapsedToolIds.value = new Set();
|
||
};
|
||
|
||
const appendMessage = (role: MessageRole, content = '', loading = false) => {
|
||
const item: BubbleMessage = {
|
||
id: uuid(),
|
||
role,
|
||
content,
|
||
createdAt: Date.now(),
|
||
loading,
|
||
};
|
||
if (role === 'assistant' && content.trim()) {
|
||
item.segments = [
|
||
{
|
||
id: uuid(),
|
||
type: 'text',
|
||
content,
|
||
},
|
||
];
|
||
}
|
||
messages.value.push(item);
|
||
scrollToBottom(true);
|
||
};
|
||
|
||
const updateLastAssistantMessage = (patch: Partial<BubbleMessage>) => {
|
||
const lastIndex = messages.value.length - 1;
|
||
if (lastIndex < 0) return;
|
||
const last = messages.value[lastIndex];
|
||
if (!last || last.role !== 'assistant') return;
|
||
messages.value[lastIndex] = { ...last, ...patch };
|
||
scrollToBottom();
|
||
};
|
||
|
||
const normalizeEventKey = (value: any) => String(value || '').toUpperCase();
|
||
const safeJsonParse = (value: string) => {
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
const safeJsonParseLoose = (value: string) => {
|
||
const parsed = safeJsonParse(value);
|
||
if (parsed) {
|
||
return parsed;
|
||
}
|
||
const firstBrace = value.indexOf('{');
|
||
const lastBrace = value.lastIndexOf('}');
|
||
if (firstBrace < 0 || lastBrace <= firstBrace) {
|
||
return null;
|
||
}
|
||
return safeJsonParse(value.slice(firstBrace, lastBrace + 1));
|
||
};
|
||
|
||
const normalizeToolPayloadValue = (value: any) => {
|
||
if (value === null || value === undefined) return '';
|
||
if (typeof value === 'string') return value;
|
||
try {
|
||
return JSON.stringify(value, null, 2);
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
};
|
||
|
||
const getLastAssistantMessage = () => {
|
||
const last = messages.value[messages.value.length - 1];
|
||
if (!last || last.role !== 'assistant') {
|
||
return null;
|
||
}
|
||
return last;
|
||
};
|
||
|
||
const appendAssistantTextDelta = (delta: string) => {
|
||
if (!delta) return;
|
||
const last = getLastAssistantMessage();
|
||
if (!last) return;
|
||
const segments = [...(last.segments || [])];
|
||
const tail = segments[segments.length - 1];
|
||
if (tail && tail.type === 'text') {
|
||
segments[segments.length - 1] = {
|
||
...tail,
|
||
content: `${tail.content}${delta}`,
|
||
};
|
||
} else {
|
||
segments.push({
|
||
id: uuid(),
|
||
type: 'text',
|
||
content: delta,
|
||
});
|
||
}
|
||
updateLastAssistantMessage({
|
||
content: `${last.content}${delta}`,
|
||
segments,
|
||
loading: true,
|
||
});
|
||
};
|
||
|
||
const getToolById = (message: BubbleMessage, toolId: string) =>
|
||
(message.toolCalls || []).find((item) => item.id === toolId);
|
||
|
||
const getRenderableSegments = (message: BubbleMessage) => {
|
||
if (message.role !== 'assistant') {
|
||
return [];
|
||
}
|
||
const segments = [...(message.segments || [])];
|
||
const hasTextSegment = segments.some(
|
||
(segment) => segment.type === 'text' && segment.content.trim() !== '',
|
||
);
|
||
if (!hasTextSegment && message.content.trim()) {
|
||
segments.push({
|
||
id: `${message.id}-fallback-text`,
|
||
type: 'text',
|
||
content: message.content,
|
||
});
|
||
}
|
||
return segments;
|
||
};
|
||
|
||
const hasAssistantRenderableContent = (message: BubbleMessage) => {
|
||
const segments = getRenderableSegments(message);
|
||
return segments.some((segment) => {
|
||
if (segment.type === 'text') {
|
||
return segment.content.trim() !== '';
|
||
}
|
||
return Boolean(getToolById(message, segment.toolId));
|
||
});
|
||
};
|
||
|
||
const upsertToolTrace = (patch: Partial<ToolTraceItem> & { id?: string }) => {
|
||
const last = getLastAssistantMessage();
|
||
if (!last) {
|
||
return;
|
||
}
|
||
const nextToolCalls = [...(last.toolCalls || [])];
|
||
let id = String(patch.id || '');
|
||
if (!id && patch.status === 'TOOL_RESULT') {
|
||
const pending = [...nextToolCalls].reverse().find((item) => item.status !== 'TOOL_RESULT');
|
||
if (pending) {
|
||
id = pending.id;
|
||
}
|
||
}
|
||
if (!id) {
|
||
id = uuid();
|
||
}
|
||
const idx = nextToolCalls.findIndex((item) => item.id === id);
|
||
const current = idx >= 0 ? nextToolCalls[idx] : null;
|
||
const next: ToolTraceItem = {
|
||
id,
|
||
name: String(patch.name || current?.name || $t('bot.publicChatToolUnknown')),
|
||
status: (patch.status || current?.status || 'TOOL_CALL') as ToolStatus,
|
||
arguments: patch.arguments ?? current?.arguments,
|
||
result: patch.result ?? current?.result,
|
||
};
|
||
if (idx >= 0) {
|
||
nextToolCalls[idx] = next;
|
||
} else {
|
||
nextToolCalls.push(next);
|
||
}
|
||
const segments = [...(last.segments || [])];
|
||
const hasToolSegment = segments.some(
|
||
(segment) => segment.type === 'tool' && segment.toolId === id,
|
||
);
|
||
if (!hasToolSegment) {
|
||
segments.push({
|
||
id: uuid(),
|
||
type: 'tool',
|
||
toolId: id,
|
||
});
|
||
const nextCollapsed = new Set(collapsedToolIds.value);
|
||
nextCollapsed.add(id);
|
||
collapsedToolIds.value = nextCollapsed;
|
||
}
|
||
updateLastAssistantMessage({
|
||
toolCalls: nextToolCalls,
|
||
segments,
|
||
loading: true,
|
||
});
|
||
};
|
||
|
||
const isToolCollapsed = (toolId: string) => collapsedToolIds.value.has(toolId);
|
||
const toggleToolCollapsed = (toolId: string) => {
|
||
const next = new Set(collapsedToolIds.value);
|
||
if (next.has(toolId)) {
|
||
next.delete(toolId);
|
||
} else {
|
||
next.add(toolId);
|
||
}
|
||
collapsedToolIds.value = next;
|
||
};
|
||
|
||
const extractToolMetaFromPayload = (payload: any) => {
|
||
if (!payload || typeof payload !== 'object') {
|
||
return null;
|
||
}
|
||
const id = payload?.tool_call_id || payload?.toolCallId || payload?.id || '';
|
||
const name = payload?.name || payload?.toolName || '';
|
||
const hasArgs = payload?.arguments !== undefined;
|
||
const hasResult = payload?.result !== undefined || payload?.content !== undefined;
|
||
if (!id && !name && !hasArgs && !hasResult) {
|
||
return null;
|
||
}
|
||
return {
|
||
id: String(id || ''),
|
||
name: String(name || ''),
|
||
arguments: hasArgs ? normalizeToolPayloadValue(payload?.arguments) : undefined,
|
||
result: hasResult
|
||
? normalizeToolPayloadValue(payload?.result !== undefined ? payload?.result : payload?.content)
|
||
: undefined,
|
||
};
|
||
};
|
||
|
||
const handleToolSseEvent = (sseData: any, normalizedType?: string) => {
|
||
const payload = sseData?.payload || {};
|
||
const inferred = extractToolMetaFromPayload(payload);
|
||
const normalized = normalizedType || normalizeEventKey(sseData?.type);
|
||
let status = normalized as ToolStatus;
|
||
if (status !== 'TOOL_CALL' && status !== 'TOOL_RESULT') {
|
||
if (inferred?.result && !inferred?.arguments) {
|
||
status = 'TOOL_RESULT';
|
||
} else if (inferred?.arguments || inferred?.name || inferred?.id) {
|
||
status = 'TOOL_CALL';
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
if (!inferred) {
|
||
return;
|
||
}
|
||
upsertToolTrace({
|
||
id: inferred.id,
|
||
name: inferred.name,
|
||
status,
|
||
arguments:
|
||
status === 'TOOL_CALL'
|
||
? inferred.arguments
|
||
: undefined,
|
||
result:
|
||
status === 'TOOL_RESULT'
|
||
? inferred.result
|
||
: undefined,
|
||
});
|
||
};
|
||
|
||
const handleNeedSaveToolMessage = (role: string, content: string) => {
|
||
if (role === 'assistant') {
|
||
const parsed = safeJsonParseLoose(content);
|
||
const toolCalls = Array.isArray(parsed?.toolCalls) ? parsed.toolCalls : [];
|
||
for (const call of toolCalls) {
|
||
upsertToolTrace({
|
||
id: String(call?.id || uuid()),
|
||
name: String(call?.name || ''),
|
||
status: 'TOOL_CALL',
|
||
arguments: normalizeToolPayloadValue(call?.arguments),
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (role === 'tool') {
|
||
const parsed = safeJsonParseLoose(content);
|
||
const toolCallId = String(parsed?.toolCallId || parsed?.tool_call_id || '');
|
||
const resultText =
|
||
typeof parsed?.content === 'string'
|
||
? parsed.content
|
||
: normalizeToolPayloadValue(parsed || content);
|
||
upsertToolTrace({
|
||
id: toolCallId || uuid(),
|
||
status: 'TOOL_RESULT',
|
||
result: resultText,
|
||
});
|
||
}
|
||
};
|
||
|
||
const onSseMessage = (event: ServerSentEventMessage) => {
|
||
if (event.event === 'done') {
|
||
sending.value = false;
|
||
updateLastAssistantMessage({ loading: false });
|
||
return;
|
||
}
|
||
if (!event.data) {
|
||
return;
|
||
}
|
||
|
||
let sseData: any = null;
|
||
try {
|
||
sseData = JSON.parse(event.data);
|
||
} catch {
|
||
return;
|
||
}
|
||
const eventDomain = normalizeEventKey(sseData?.domain);
|
||
const eventType = normalizeEventKey(sseData?.type);
|
||
if (
|
||
eventDomain === 'SYSTEM' &&
|
||
sseData?.payload?.code === 'SYSTEM_ERROR'
|
||
) {
|
||
sending.value = false;
|
||
updateLastAssistantMessage({
|
||
content: sseData.payload?.message || $t('bot.publicChatInitError'),
|
||
loading: false,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (
|
||
eventDomain === 'TOOL'
|
||
|| eventType === 'TOOL_CALL'
|
||
|| eventType === 'TOOL_RESULT'
|
||
) {
|
||
handleToolSseEvent(sseData, eventType);
|
||
return;
|
||
}
|
||
|
||
if (event.event === 'needSaveMessage') {
|
||
const role = String(sseData?.payload?.role || '').trim().toLowerCase();
|
||
const content = sseData?.payload?.content;
|
||
if (
|
||
(role === 'assistant' || role === 'user' || role === 'system' || role === 'tool')
|
||
&& typeof content === 'string'
|
||
) {
|
||
historyMessages.value.push({ role, content });
|
||
handleNeedSaveToolMessage(role, content);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (eventType === 'MESSAGE' && eventDomain === 'LLM') {
|
||
const deltaRaw = sseData?.payload?.delta ?? sseData?.payload?.content ?? '';
|
||
const delta = typeof deltaRaw === 'string' ? deltaRaw : normalizeToolPayloadValue(deltaRaw);
|
||
appendAssistantTextDelta(delta);
|
||
}
|
||
|
||
handleToolSseEvent(sseData, eventType);
|
||
};
|
||
|
||
const doSendMessage = () => {
|
||
const prompt = senderValue.value.trim();
|
||
if (!prompt || sending.value || blocked.value || !conversationId.value) {
|
||
return;
|
||
}
|
||
|
||
appendMessage('user', prompt);
|
||
appendMessage('assistant', '', true);
|
||
shouldAutoScroll.value = true;
|
||
userStoppedStreaming.value = false;
|
||
senderValue.value = '';
|
||
sending.value = true;
|
||
|
||
const requestMessages = [...historyMessages.value, { role: 'user', content: prompt }];
|
||
|
||
sseClient.post(
|
||
'/api/v1/bot/chat',
|
||
{
|
||
botId: botId.value,
|
||
prompt,
|
||
conversationId: conversationId.value,
|
||
messages: requestMessages,
|
||
},
|
||
{
|
||
onMessage: onSseMessage,
|
||
onFinished: () => {
|
||
sending.value = false;
|
||
updateLastAssistantMessage({ loading: false });
|
||
},
|
||
onError: (error: any) => {
|
||
sending.value = false;
|
||
const abortLike =
|
||
userStoppedStreaming.value
|
||
|| error?.name === 'AbortError'
|
||
|| String(error?.message || '').includes('aborted');
|
||
if (abortLike) {
|
||
const last = messages.value[messages.value.length - 1];
|
||
if (last?.role === 'assistant' && !last.content.trim()) {
|
||
updateLastAssistantMessage({
|
||
loading: false,
|
||
content: $t('bot.publicChatStopped'),
|
||
});
|
||
} else {
|
||
updateLastAssistantMessage({ loading: false });
|
||
}
|
||
return;
|
||
}
|
||
updateLastAssistantMessage({
|
||
loading: false,
|
||
content: $t('bot.publicChatInitError'),
|
||
});
|
||
},
|
||
},
|
||
);
|
||
};
|
||
|
||
const stopStreaming = () => {
|
||
userStoppedStreaming.value = true;
|
||
sseClient.abort();
|
||
sending.value = false;
|
||
const last = messages.value[messages.value.length - 1];
|
||
if (last?.role === 'assistant' && !last.content.trim()) {
|
||
updateLastAssistantMessage({
|
||
loading: false,
|
||
content: $t('bot.publicChatStopped'),
|
||
});
|
||
return;
|
||
}
|
||
updateLastAssistantMessage({ loading: false });
|
||
};
|
||
|
||
const onEnter = (evt: Event | KeyboardEvent) => {
|
||
if (!(evt instanceof KeyboardEvent)) {
|
||
return;
|
||
}
|
||
// 输入法组合输入期间,Enter 仅用于上屏,不触发发送
|
||
if (isComposing.value || evt.isComposing || evt.keyCode === 229) {
|
||
return;
|
||
}
|
||
if (evt.shiftKey) return;
|
||
evt.preventDefault();
|
||
doSendMessage();
|
||
};
|
||
|
||
const onCompositionStart = () => {
|
||
isComposing.value = true;
|
||
};
|
||
|
||
const onCompositionEnd = () => {
|
||
isComposing.value = false;
|
||
};
|
||
|
||
const usePresetQuestion = (question: string) => {
|
||
senderValue.value = question;
|
||
doSendMessage();
|
||
};
|
||
|
||
const initPublicChat = async () => {
|
||
resetConversationState();
|
||
initializing.value = true;
|
||
setPageTitle();
|
||
|
||
try {
|
||
if (!accessStore.accessToken) {
|
||
const tokenResp = await api.get('/api/temp-token/create');
|
||
if (tokenResp.errorCode !== 0 || !tokenResp.data) {
|
||
throw new Error($t('bot.publicChatInitError'));
|
||
}
|
||
accessStore.setAccessToken(tokenResp.data);
|
||
}
|
||
|
||
const botResp = await api.get('/api/v1/bot/getDetail', {
|
||
params: { id: botId.value },
|
||
});
|
||
if (botResp.errorCode !== 0 || !botResp.data) {
|
||
throw new Error($t('bot.publicChatInitError'));
|
||
}
|
||
botInfo.value = botResp.data;
|
||
setPageTitle();
|
||
blocked.value = !Boolean(botResp.data?.options?.anonymousEnabled);
|
||
|
||
const conversationResp = await api.get('/api/v1/bot/generateConversationId');
|
||
if (conversationResp.errorCode !== 0 || !conversationResp.data) {
|
||
throw new Error($t('bot.publicChatInitError'));
|
||
}
|
||
conversationId.value = String(conversationResp.data);
|
||
|
||
const welcomeMessage = botResp.data?.options?.welcomeMessage;
|
||
if (typeof welcomeMessage === 'string' && welcomeMessage.trim() !== '') {
|
||
appendMessage('assistant', welcomeMessage.trim());
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
initError.value = $t('bot.publicChatInitError');
|
||
setPageTitle();
|
||
} finally {
|
||
initializing.value = false;
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
initPublicChat();
|
||
nextTick(() => {
|
||
messageContainerRef.value?.addEventListener('scroll', onMessageContainerScroll, {
|
||
passive: true,
|
||
});
|
||
updateDockHeight();
|
||
if (dockRef.value && typeof ResizeObserver !== 'undefined') {
|
||
dockResizeObserver = new ResizeObserver(() => {
|
||
updateDockHeight();
|
||
});
|
||
dockResizeObserver.observe(dockRef.value);
|
||
}
|
||
});
|
||
});
|
||
|
||
watch(
|
||
() => route.params.botId,
|
||
() => {
|
||
initPublicChat();
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => messages.value.length,
|
||
() => {
|
||
scrollToBottom();
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => [
|
||
initializing.value,
|
||
initError.value,
|
||
blocked.value,
|
||
presetQuestions.value.length,
|
||
sending.value,
|
||
],
|
||
() => {
|
||
nextTick(updateDockHeight);
|
||
},
|
||
{ flush: 'post' },
|
||
);
|
||
|
||
onBeforeUnmount(() => {
|
||
sseClient.abort();
|
||
messageContainerRef.value?.removeEventListener('scroll', onMessageContainerScroll);
|
||
dockResizeObserver?.disconnect();
|
||
dockResizeObserver = null;
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
:class="[
|
||
'public-chat-page',
|
||
isEmbedMode ? 'public-chat-page-embed' : '',
|
||
]"
|
||
>
|
||
<div class="public-chat-shell">
|
||
<header class="public-chat-header">
|
||
<div class="public-chat-title-group">
|
||
<ElAvatar :size="40" :src="botInfo?.icon || defaultBotAvatar" />
|
||
<div class="public-chat-title-wrap">
|
||
<h1 class="public-chat-title">
|
||
{{ botInfo?.title || $t('bot.publicChatTitle') }}
|
||
</h1>
|
||
<p class="public-chat-subtitle">
|
||
{{ $t('bot.publicChatSubtitle') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main ref="messageContainerRef" class="public-chat-messages" :style="messageContainerStyle">
|
||
<ElSkeleton :loading="initializing" animated :rows="5">
|
||
<template #default>
|
||
<div v-if="initError" class="public-chat-banner public-chat-banner-error">
|
||
{{ initError }}
|
||
</div>
|
||
<div v-else-if="blocked" class="public-chat-banner public-chat-banner-warning">
|
||
<p class="public-chat-banner-title">{{ $t('bot.publicPageBlocked') }}</p>
|
||
<p>{{ $t('bot.publicPageBlockedTip') }}</p>
|
||
</div>
|
||
<template v-else>
|
||
<div
|
||
v-for="item in messages"
|
||
:key="item.id"
|
||
:class="[
|
||
'public-chat-message-row',
|
||
item.role === 'user' ? 'is-user' : 'is-assistant',
|
||
]"
|
||
>
|
||
<ElAvatar
|
||
v-if="item.role === 'assistant'"
|
||
:size="34"
|
||
:src="botInfo?.icon || defaultBotAvatar"
|
||
class="public-chat-message-avatar"
|
||
/>
|
||
<div class="public-chat-message">
|
||
<div
|
||
:class="[
|
||
'public-chat-bubble',
|
||
item.role === 'user' ? 'public-chat-bubble-user' : 'public-chat-bubble-assistant',
|
||
]"
|
||
>
|
||
<template v-if="item.role === 'user'">
|
||
<span>{{ item.content }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="item.loading && !hasAssistantRenderableContent(item)">
|
||
<span class="public-chat-waiting">
|
||
<span class="public-chat-waiting-spinner"></span>
|
||
<span>{{ $t('bot.publicChatThinking') }}</span>
|
||
</span>
|
||
</template>
|
||
<template v-else>
|
||
<div
|
||
v-for="segment in getRenderableSegments(item)"
|
||
:key="segment.id"
|
||
:class="[
|
||
'public-chat-segment',
|
||
segment.type === 'tool' ? 'is-tool' : 'is-text',
|
||
]"
|
||
>
|
||
<template v-if="segment.type === 'text'">
|
||
<ElXMarkdown
|
||
class="public-chat-markdown"
|
||
:markdown="segment.content"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<template v-if="getToolById(item, segment.toolId)">
|
||
<div class="public-chat-tool-segment">
|
||
<div class="public-chat-tool-item">
|
||
<div class="public-chat-tool-header">
|
||
<span class="public-chat-tool-name">
|
||
<IconifyIcon icon="solar:widget-5-outline" />
|
||
{{ getToolById(item, segment.toolId)?.name }}
|
||
</span>
|
||
<div class="public-chat-tool-actions">
|
||
<span
|
||
:class="[
|
||
'public-chat-tool-status',
|
||
getToolById(item, segment.toolId)?.status === 'TOOL_RESULT' ? 'is-done' : 'is-calling',
|
||
]"
|
||
>
|
||
{{
|
||
getToolById(item, segment.toolId)?.status === 'TOOL_RESULT'
|
||
? $t('bot.publicChatToolDone')
|
||
: $t('bot.publicChatToolCalling')
|
||
}}
|
||
</span>
|
||
<button
|
||
class="public-chat-tool-toggle"
|
||
type="button"
|
||
@click="toggleToolCollapsed(segment.toolId)"
|
||
>
|
||
{{
|
||
isToolCollapsed(segment.toolId)
|
||
? $t('bot.publicChatToolExpand')
|
||
: $t('bot.publicChatToolCollapse')
|
||
}}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<pre
|
||
v-if="!isToolCollapsed(segment.toolId) && getToolById(item, segment.toolId)?.arguments"
|
||
class="public-chat-tool-content"
|
||
>{{ getToolById(item, segment.toolId)?.arguments }}</pre>
|
||
<pre
|
||
v-if="!isToolCollapsed(segment.toolId) && getToolById(item, segment.toolId)?.result && getToolById(item, segment.toolId)?.status === 'TOOL_RESULT'"
|
||
class="public-chat-tool-content"
|
||
>{{ getToolById(item, segment.toolId)?.result }}</pre>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
<span v-if="item.loading" class="public-chat-dot-typing">
|
||
<i></i><i></i><i></i>
|
||
</span>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</ElSkeleton>
|
||
</main>
|
||
|
||
<div ref="dockRef" class="public-chat-dock">
|
||
<section v-if="!initializing && !initError && !blocked" class="public-chat-preset-list">
|
||
<button
|
||
v-for="item in presetQuestions"
|
||
:key="item.key"
|
||
class="public-chat-preset-item"
|
||
type="button"
|
||
@click="usePresetQuestion(item.description)"
|
||
>
|
||
{{ item.description }}
|
||
</button>
|
||
</section>
|
||
|
||
<footer class="public-chat-footer">
|
||
<div class="public-chat-composer">
|
||
<ElInput
|
||
class="public-chat-input"
|
||
v-model="senderValue"
|
||
type="textarea"
|
||
:rows="2"
|
||
resize="none"
|
||
:placeholder="$t('bot.publicChatPlaceholder')"
|
||
:disabled="blocked || initializing || !!initError || sending"
|
||
@keydown.enter="onEnter"
|
||
@compositionstart="onCompositionStart"
|
||
@compositionend="onCompositionEnd"
|
||
/>
|
||
<span class="public-chat-footer-hint">
|
||
{{ $t('bot.publicChatInputHint') }}
|
||
</span>
|
||
<ElButton
|
||
v-if="sending"
|
||
plain
|
||
class="public-chat-input-send-btn is-stop"
|
||
@click="stopStreaming"
|
||
>
|
||
<template #icon>
|
||
<IconifyIcon icon="solar:stop-circle-outline" />
|
||
</template>
|
||
{{ $t('bot.publicChatStop') }}
|
||
</ElButton>
|
||
<ElButton
|
||
v-else
|
||
type="primary"
|
||
class="public-chat-input-send-btn"
|
||
:disabled="blocked || initializing || !!initError || !senderValue.trim()"
|
||
@click="doSendMessage"
|
||
>
|
||
<template #icon>
|
||
<IconifyIcon icon="solar:plain-linear" />
|
||
</template>
|
||
{{ $t('bot.publicChatSend') }}
|
||
</ElButton>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.public-chat-page {
|
||
--pc-primary: #2563eb;
|
||
--pc-primary-soft: #dbeafe;
|
||
--pc-ink-strong: #0f172a;
|
||
--pc-ink: #1e293b;
|
||
--pc-ink-soft: #475569;
|
||
--pc-line: #e2e8f0;
|
||
--pc-line-soft: #edf2f7;
|
||
--pc-surface: #ffffff;
|
||
--pc-surface-soft: #f8fbff;
|
||
--pc-surface-muted: #f8fafc;
|
||
box-sizing: border-box;
|
||
position: relative;
|
||
isolation: isolate;
|
||
min-height: 100vh;
|
||
padding: 24px;
|
||
overflow: hidden;
|
||
background:
|
||
radial-gradient(1200px 360px at 100% -20%, rgb(37 99 235 / 14%) 0%, transparent 60%),
|
||
radial-gradient(900px 300px at -10% 120%, rgb(14 165 233 / 12%) 0%, transparent 62%),
|
||
linear-gradient(180deg, #f8fbff 0%, #f3f7fc 100%);
|
||
}
|
||
|
||
.public-chat-page::before {
|
||
position: absolute;
|
||
inset: -30% auto auto -20%;
|
||
z-index: -1;
|
||
width: 440px;
|
||
height: 440px;
|
||
content: '';
|
||
pointer-events: none;
|
||
background: radial-gradient(circle, rgb(59 130 246 / 16%) 0%, transparent 68%);
|
||
filter: blur(8px);
|
||
}
|
||
|
||
.public-chat-page-embed {
|
||
min-height: 100%;
|
||
padding: 0;
|
||
background: var(--pc-surface-soft);
|
||
}
|
||
|
||
.public-chat-page-embed::before {
|
||
content: none;
|
||
}
|
||
|
||
.public-chat-shell {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-width: 980px;
|
||
height: calc(100vh - 48px);
|
||
margin: 0 auto;
|
||
overflow: hidden;
|
||
background: var(--pc-surface);
|
||
border: 1px solid var(--pc-line);
|
||
border-radius: 20px;
|
||
box-shadow:
|
||
0 2px 6px rgb(15 23 42 / 5%),
|
||
0 24px 48px rgb(15 23 42 / 8%);
|
||
}
|
||
|
||
.public-chat-page-embed .public-chat-shell {
|
||
height: 100vh;
|
||
border: 0;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.public-chat-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 20px 24px;
|
||
background: rgb(255 255 255 / 92%);
|
||
border-bottom: 1px solid var(--pc-line-soft);
|
||
backdrop-filter: saturate(130%) blur(6px);
|
||
}
|
||
|
||
.public-chat-title-group {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.public-chat-title-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.public-chat-title {
|
||
margin: 0;
|
||
font-family: Manrope, 'Noto Sans SC', sans-serif;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
line-height: 1.35;
|
||
letter-spacing: 0.01em;
|
||
color: var(--pc-ink-strong);
|
||
}
|
||
|
||
.public-chat-subtitle {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.public-chat-messages {
|
||
flex: 1;
|
||
padding: 20px 24px var(--public-chat-dock-space, 180px);
|
||
overflow-y: auto;
|
||
background: rgb(255 255 255);
|
||
}
|
||
|
||
.public-chat-banner {
|
||
padding: 14px 16px;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.public-chat-banner-title {
|
||
margin: 0 0 4px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.public-chat-banner-error {
|
||
color: #b42318;
|
||
background: #fff1f2;
|
||
border: 1px solid #fecdd3;
|
||
}
|
||
|
||
.public-chat-banner-warning {
|
||
color: #9a3412;
|
||
background: #fffbeb;
|
||
border: 1px solid #fed7aa;
|
||
}
|
||
|
||
.public-chat-message-row {
|
||
display: flex;
|
||
margin-bottom: 18px;
|
||
animation: message-fade-in 0.22s ease;
|
||
}
|
||
|
||
.public-chat-message-row.is-user {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.public-chat-message-row.is-assistant {
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.public-chat-message-avatar {
|
||
flex: 0 0 auto;
|
||
margin-top: 1px;
|
||
}
|
||
|
||
.public-chat-message {
|
||
min-width: 0;
|
||
max-width: min(88%, 760px);
|
||
}
|
||
|
||
.public-chat-bubble {
|
||
display: inline-block;
|
||
min-width: 72px;
|
||
min-height: 44px;
|
||
padding: 11px 14px;
|
||
font-size: 14px;
|
||
line-height: 1.7;
|
||
white-space: pre-wrap;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.public-chat-bubble-user {
|
||
color: #ffffff;
|
||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||
border-top-right-radius: 4px;
|
||
box-shadow: 0 6px 16px rgb(37 99 235 / 24%);
|
||
}
|
||
|
||
.public-chat-bubble-assistant {
|
||
display: block;
|
||
width: 100%;
|
||
min-width: 0;
|
||
min-height: auto;
|
||
padding: 0;
|
||
color: #0f172a;
|
||
background: transparent;
|
||
border: 0;
|
||
border-radius: 0;
|
||
}
|
||
|
||
.public-chat-dot-typing {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
margin-left: 6px;
|
||
}
|
||
|
||
.public-chat-dot-typing i {
|
||
width: 4px;
|
||
height: 4px;
|
||
background: #64748b;
|
||
border-radius: 50%;
|
||
animation: dot-pulse 1.2s infinite ease-in-out;
|
||
}
|
||
|
||
.public-chat-dot-typing i:nth-child(2) {
|
||
animation-delay: 0.2s;
|
||
}
|
||
|
||
.public-chat-dot-typing i:nth-child(3) {
|
||
animation-delay: 0.4s;
|
||
}
|
||
|
||
.public-chat-segment {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.public-chat-segment:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.public-chat-segment.is-text {
|
||
white-space: normal;
|
||
}
|
||
|
||
.public-chat-segment.is-tool {
|
||
display: block;
|
||
margin: 12px 0;
|
||
}
|
||
|
||
.public-chat-waiting {
|
||
display: inline-flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
color: var(--pc-ink-soft);
|
||
}
|
||
|
||
.public-chat-waiting-spinner {
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid #bfdbfe;
|
||
border-top-color: #2563eb;
|
||
border-radius: 50%;
|
||
animation: waiting-spin 0.8s linear infinite;
|
||
}
|
||
|
||
.public-chat-tool-segment {
|
||
width: 560px;
|
||
max-width: 560px;
|
||
}
|
||
|
||
.public-chat-message-row.is-assistant .public-chat-tool-segment {
|
||
max-width: min(560px, 100%);
|
||
}
|
||
|
||
.public-chat-tool-item {
|
||
width: 100%;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
padding: 10px 12px;
|
||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||
border: 1px solid #d7e2f2;
|
||
border-left: 3px solid var(--pc-primary);
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 6px rgb(15 23 42 / 5%);
|
||
}
|
||
|
||
.public-chat-markdown {
|
||
color: var(--pc-ink);
|
||
}
|
||
|
||
.public-chat-markdown :deep(*) {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.public-chat-markdown :deep(p) {
|
||
margin: 0 0 10px;
|
||
}
|
||
|
||
.public-chat-markdown :deep(p:last-child) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.public-chat-markdown :deep(a) {
|
||
color: var(--pc-primary);
|
||
text-decoration: underline;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.public-chat-markdown :deep(img) {
|
||
display: block;
|
||
max-width: min(100%, 420px);
|
||
height: auto;
|
||
margin: 10px 0;
|
||
border: 1px solid #dbe5f1;
|
||
border-radius: 10px;
|
||
box-shadow: 0 6px 18px rgb(15 23 42 / 8%);
|
||
}
|
||
|
||
.public-chat-markdown :deep(pre) {
|
||
padding: 10px 12px;
|
||
overflow: auto;
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.public-chat-tool-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
.public-chat-tool-actions {
|
||
display: inline-flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.public-chat-tool-name {
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
overflow: hidden;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--pc-ink-strong);
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.public-chat-tool-status {
|
||
flex-shrink: 0;
|
||
padding: 2px 8px;
|
||
font-size: 11px;
|
||
line-height: 1.25;
|
||
border: 1px solid transparent;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.public-chat-tool-status.is-calling {
|
||
color: #1d4ed8;
|
||
background: #eff6ff;
|
||
border-color: #bfdbfe;
|
||
}
|
||
|
||
.public-chat-tool-status.is-done {
|
||
color: #15803d;
|
||
background: #ecfdf3;
|
||
border-color: #bbf7d0;
|
||
}
|
||
|
||
.public-chat-tool-toggle {
|
||
padding: 3px 10px;
|
||
font-size: 11px;
|
||
line-height: 1.4;
|
||
color: #334155;
|
||
cursor: pointer;
|
||
background: #ffffff;
|
||
border: 1px solid #d7e2f2;
|
||
border-radius: 999px;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.public-chat-tool-toggle:hover {
|
||
color: #1d4ed8;
|
||
border-color: #bfdbfe;
|
||
}
|
||
|
||
.public-chat-tool-content {
|
||
max-height: 160px;
|
||
padding: 9px 10px;
|
||
margin: 8px 0 0;
|
||
overflow: auto;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||
'Courier New', monospace;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: #334155;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
background: #ffffff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.public-chat-dock {
|
||
position: absolute;
|
||
right: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
z-index: 12;
|
||
padding: 0 24px 18px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.public-chat-preset-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
padding: 0 4px 0;
|
||
margin-bottom: 10px;
|
||
pointer-events: auto;
|
||
background: transparent;
|
||
border-top: 0;
|
||
}
|
||
|
||
.public-chat-preset-item {
|
||
max-width: 100%;
|
||
padding: 8px 14px;
|
||
overflow: hidden;
|
||
font-size: 12px;
|
||
color: #334155;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
background: rgb(255 255 255 / 96%);
|
||
border: 1px solid #dbe3ef;
|
||
border-radius: 999px;
|
||
box-shadow: 0 4px 12px rgb(15 23 42 / 8%);
|
||
transition: all 0.18s ease;
|
||
}
|
||
|
||
.public-chat-preset-item:hover {
|
||
color: #1d4ed8;
|
||
background: #eff6ff;
|
||
border-color: #bfdbfe;
|
||
}
|
||
|
||
.public-chat-footer {
|
||
padding: 0 10px;
|
||
pointer-events: auto;
|
||
background: transparent;
|
||
border-top: 0;
|
||
}
|
||
|
||
.public-chat-composer {
|
||
position: relative;
|
||
padding: 0 0 4px;
|
||
overflow: hidden;
|
||
background: rgb(255 255 255);
|
||
border: 1px solid #dce5f1;
|
||
border-radius: 22px;
|
||
box-shadow:
|
||
0 8px 26px rgb(15 23 42 / 10%),
|
||
0 1px 0 rgb(255 255 255 / 95%) inset;
|
||
}
|
||
|
||
.public-chat-footer-hint {
|
||
position: absolute;
|
||
bottom: 16px;
|
||
left: 18px;
|
||
z-index: 1;
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
opacity: 0.9;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.public-chat-input-send-btn {
|
||
position: absolute;
|
||
right: 14px;
|
||
bottom: 14px;
|
||
z-index: 2;
|
||
height: 40px;
|
||
padding: 0 15px;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.public-chat-input-send-btn.is-stop {
|
||
padding-right: 12px;
|
||
padding-left: 12px;
|
||
}
|
||
|
||
.public-chat-input :deep(.el-textarea__inner) {
|
||
min-height: 126px !important;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
background: rgb(255 255 255);
|
||
border-color: transparent;
|
||
border-radius: 20px;
|
||
padding: 16px 124px 48px 18px;
|
||
box-shadow: none;
|
||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||
}
|
||
|
||
.public-chat-input :deep(.el-textarea) {
|
||
background: rgb(255 255 255);
|
||
}
|
||
|
||
.public-chat-input :deep(.el-textarea__inner:focus) {
|
||
border-color: transparent;
|
||
box-shadow: 0 0 0 3px rgb(37 99 235 / 10%);
|
||
}
|
||
|
||
@keyframes message-fade-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(8px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
@keyframes dot-pulse {
|
||
0%,
|
||
80%,
|
||
100% {
|
||
transform: scale(0.75);
|
||
opacity: 0.5;
|
||
}
|
||
40% {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes waiting-spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@media (width <= 768px) {
|
||
.public-chat-page {
|
||
padding: 0;
|
||
}
|
||
|
||
.public-chat-shell {
|
||
height: 100vh;
|
||
border: 0;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.public-chat-header,
|
||
.public-chat-messages {
|
||
padding-right: 14px;
|
||
padding-left: 14px;
|
||
}
|
||
|
||
.public-chat-dock {
|
||
padding-right: 14px;
|
||
padding-bottom: 12px;
|
||
padding-left: 14px;
|
||
}
|
||
|
||
.public-chat-preset-list {
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
padding-right: 2px;
|
||
padding-left: 2px;
|
||
}
|
||
|
||
.public-chat-footer {
|
||
padding-right: 6px;
|
||
padding-left: 6px;
|
||
}
|
||
|
||
.public-chat-message {
|
||
max-width: 94%;
|
||
}
|
||
|
||
.public-chat-input :deep(.el-textarea__inner) {
|
||
min-height: 118px !important;
|
||
padding-right: 118px;
|
||
padding-bottom: 46px;
|
||
}
|
||
}
|
||
</style>
|