2179 lines
56 KiB
Vue
2179 lines
56 KiB
Vue
<script setup lang="ts">
|
||
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||
|
||
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
|
||
import type { BotInfo } from '@easyflow/types';
|
||
|
||
import {
|
||
computed,
|
||
nextTick,
|
||
onBeforeUnmount,
|
||
onMounted,
|
||
ref,
|
||
watch,
|
||
} from 'vue';
|
||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
|
||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||
import { LOGIN_PATH } from '@easyflow/constants';
|
||
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 { refreshTokenApi } from '#/api/core';
|
||
import { baseRequestClient, 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;
|
||
}
|
||
|
||
interface AssistantThinkingSegment {
|
||
id: string;
|
||
type: 'thinking';
|
||
content: string;
|
||
expanded: boolean;
|
||
status: ChatThinkingBlockStatus;
|
||
}
|
||
|
||
type AssistantSegment =
|
||
| AssistantTextSegment
|
||
| AssistantThinkingSegment
|
||
| AssistantToolSegment;
|
||
|
||
interface BubbleMessage {
|
||
id: string;
|
||
role: MessageRole;
|
||
content: string;
|
||
createdAt: number;
|
||
loading?: boolean;
|
||
toolCalls?: ToolTraceItem[];
|
||
segments?: AssistantSegment[];
|
||
}
|
||
|
||
interface PublicChatApiResponse<T> {
|
||
data?: T;
|
||
errorCode?: number;
|
||
message?: string;
|
||
}
|
||
|
||
interface PublicChatLoginResult {
|
||
token?: string;
|
||
}
|
||
|
||
interface PublicChatSessionContext {
|
||
version: number;
|
||
botId: string;
|
||
accessToken: string;
|
||
authenticatedAccess: boolean;
|
||
conversationId: string;
|
||
updatedAt: number;
|
||
}
|
||
|
||
interface PublicChatMessageRecord {
|
||
id?: number | string;
|
||
senderRole?: string;
|
||
contentText?: string;
|
||
contentPayload?: null | Record<string, any>;
|
||
created?: number | string;
|
||
}
|
||
|
||
interface PublicChatSessionRestoreResult {
|
||
sessionExists?: boolean;
|
||
conversationId?: string;
|
||
session?: null | Record<string, any>;
|
||
messages?: PublicChatMessageRecord[];
|
||
}
|
||
|
||
type AuthResolutionStatus = 'authenticated' | 'error' | 'unauthenticated';
|
||
|
||
interface AuthResolutionResult {
|
||
message?: string;
|
||
nextToken?: string;
|
||
status: AuthResolutionStatus;
|
||
}
|
||
|
||
const PUBLIC_CHAT_API_KEY_QUERY_KEYS = ['token', 'apikey', 'apiKey'] as const;
|
||
const PUBLIC_CHAT_CONTEXT_VERSION = 1;
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const pageTitle = useTitle();
|
||
const accessStore = useAccessStore();
|
||
|
||
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 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 requestAccessToken = ref('');
|
||
const authenticatedAccess = ref(false);
|
||
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 getUnauthenticatedMessage = () => $t('bot.publicPageLoginRequired');
|
||
|
||
const normalizePublicChatErrorMessage = (message?: string) => {
|
||
const trimmed = String(message || '').trim();
|
||
if (!trimmed) {
|
||
return $t('bot.publicChatInitError');
|
||
}
|
||
if (trimmed === '请登录' || trimmed === '用户未认证,请先登录') {
|
||
return getUnauthenticatedMessage();
|
||
}
|
||
return trimmed;
|
||
};
|
||
|
||
const goToLogin = async () => {
|
||
await router.push({
|
||
path: LOGIN_PATH,
|
||
query: {
|
||
redirect: encodeURIComponent(route.fullPath),
|
||
},
|
||
});
|
||
};
|
||
|
||
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 buildRequestHeaders = (
|
||
token = requestAccessToken.value,
|
||
): Record<string, string> | undefined =>
|
||
token
|
||
? {
|
||
'easyflow-token': token,
|
||
}
|
||
: undefined;
|
||
|
||
const getResponseBody = async <T,>(request: Promise<any>) => {
|
||
const response = await request;
|
||
return (response?.data || {}) as PublicChatApiResponse<T>;
|
||
};
|
||
|
||
const getPublicChatContextKey = (botIdValue: string) =>
|
||
`easyflow:public-chat:${botIdValue}`;
|
||
|
||
const readPublicChatContext = (
|
||
botIdValue: string,
|
||
): null | PublicChatSessionContext => {
|
||
if (!botIdValue || typeof window === 'undefined') {
|
||
return null;
|
||
}
|
||
const raw = window.sessionStorage.getItem(
|
||
getPublicChatContextKey(botIdValue),
|
||
);
|
||
if (!raw) {
|
||
return null;
|
||
}
|
||
const parsed = safeJsonParse(raw);
|
||
if (!parsed || typeof parsed !== 'object') {
|
||
return null;
|
||
}
|
||
if (String(parsed.botId || '') !== botIdValue) {
|
||
return null;
|
||
}
|
||
return {
|
||
version: Number(parsed.version || PUBLIC_CHAT_CONTEXT_VERSION),
|
||
botId: String(parsed.botId || botIdValue),
|
||
accessToken: String(parsed.accessToken || ''),
|
||
authenticatedAccess: Boolean(parsed.authenticatedAccess),
|
||
conversationId: String(parsed.conversationId || ''),
|
||
updatedAt: Number(parsed.updatedAt || Date.now()),
|
||
};
|
||
};
|
||
|
||
const writePublicChatContext = (context: PublicChatSessionContext) => {
|
||
if (!context.botId || typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
window.sessionStorage.setItem(
|
||
getPublicChatContextKey(context.botId),
|
||
JSON.stringify(context),
|
||
);
|
||
};
|
||
|
||
const clearPublicChatContext = (botIdValue = botId.value) => {
|
||
if (!botIdValue || typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
window.sessionStorage.removeItem(getPublicChatContextKey(botIdValue));
|
||
};
|
||
|
||
const upsertPublicChatContext = (
|
||
patch: Partial<PublicChatSessionContext>,
|
||
options?: { resetConversation?: boolean },
|
||
) => {
|
||
const current =
|
||
readPublicChatContext(botId.value) ||
|
||
({
|
||
version: PUBLIC_CHAT_CONTEXT_VERSION,
|
||
botId: botId.value,
|
||
accessToken: '',
|
||
authenticatedAccess: false,
|
||
conversationId: '',
|
||
updatedAt: Date.now(),
|
||
} as PublicChatSessionContext);
|
||
const next: PublicChatSessionContext = {
|
||
...current,
|
||
...patch,
|
||
version: PUBLIC_CHAT_CONTEXT_VERSION,
|
||
botId: botId.value,
|
||
updatedAt: Date.now(),
|
||
};
|
||
if (options?.resetConversation) {
|
||
next.conversationId = '';
|
||
}
|
||
writePublicChatContext(next);
|
||
return next;
|
||
};
|
||
|
||
const normalizeTimestamp = (value: any) => {
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
return value;
|
||
}
|
||
const parsed = Date.parse(String(value || ''));
|
||
return Number.isNaN(parsed) ? Date.now() : parsed;
|
||
};
|
||
|
||
const restoreToolTraceFromChain = (chain: Record<string, any>) => {
|
||
const toolId = String(chain.id || '').trim() || uuid();
|
||
const status =
|
||
String(chain.status || '').toUpperCase() === 'TOOL_CALL'
|
||
? 'TOOL_CALL'
|
||
: 'TOOL_RESULT';
|
||
return {
|
||
id: toolId,
|
||
name: String(chain.name || $t('bot.publicChatToolUnknown')),
|
||
status,
|
||
arguments:
|
||
status === 'TOOL_CALL'
|
||
? normalizeToolPayloadValue(chain.arguments ?? chain.result)
|
||
: undefined,
|
||
result:
|
||
status === 'TOOL_RESULT'
|
||
? normalizeToolPayloadValue(chain.result)
|
||
: undefined,
|
||
} as ToolTraceItem;
|
||
};
|
||
|
||
const buildAssistantMessageFromRecord = (
|
||
record: PublicChatMessageRecord,
|
||
): BubbleMessage => {
|
||
const contentText = String(record.contentText || '');
|
||
const toolCalls: ToolTraceItem[] = [];
|
||
const segments: AssistantSegment[] = [];
|
||
const chains = Array.isArray(record.contentPayload?.chains)
|
||
? record.contentPayload?.chains
|
||
: [];
|
||
|
||
for (const rawChain of chains) {
|
||
const chain =
|
||
rawChain && typeof rawChain === 'object'
|
||
? (rawChain as Record<string, any>)
|
||
: null;
|
||
if (!chain) {
|
||
continue;
|
||
}
|
||
const reasoningContent = String(chain.reasoning_content || '').trim();
|
||
if (reasoningContent) {
|
||
segments.push({
|
||
id: uuid(),
|
||
type: 'thinking',
|
||
content: reasoningContent,
|
||
expanded: false,
|
||
status:
|
||
String(chain.thinkingStatus || '').toLowerCase() === 'thinking'
|
||
? 'thinking'
|
||
: 'end',
|
||
});
|
||
continue;
|
||
}
|
||
if (
|
||
chain.id ||
|
||
chain.name ||
|
||
chain.status ||
|
||
chain.result ||
|
||
chain.arguments
|
||
) {
|
||
const tool = restoreToolTraceFromChain(chain);
|
||
toolCalls.push(tool);
|
||
segments.push({
|
||
id: uuid(),
|
||
type: 'tool',
|
||
toolId: tool.id,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (contentText.trim()) {
|
||
segments.push({
|
||
id: uuid(),
|
||
type: 'text',
|
||
content: contentText,
|
||
});
|
||
}
|
||
|
||
return {
|
||
id: String(record.id || uuid()),
|
||
role: 'assistant',
|
||
content: contentText,
|
||
createdAt: normalizeTimestamp(record.created),
|
||
loading: false,
|
||
toolCalls,
|
||
segments,
|
||
};
|
||
};
|
||
|
||
const restoreMessagesFromRecords = (records: PublicChatMessageRecord[]) => {
|
||
const restoredMessages: BubbleMessage[] = [];
|
||
const nextCollapsedToolIds = new Set<string>();
|
||
|
||
for (const record of records) {
|
||
const role = String(record.senderRole || '')
|
||
.trim()
|
||
.toLowerCase();
|
||
if (role === 'assistant') {
|
||
const assistantMessage = buildAssistantMessageFromRecord(record);
|
||
assistantMessage.toolCalls?.forEach((item) =>
|
||
nextCollapsedToolIds.add(item.id),
|
||
);
|
||
restoredMessages.push(assistantMessage);
|
||
continue;
|
||
}
|
||
restoredMessages.push({
|
||
id: String(record.id || uuid()),
|
||
role: 'user',
|
||
content: String(record.contentText || ''),
|
||
createdAt: normalizeTimestamp(record.created),
|
||
loading: false,
|
||
});
|
||
}
|
||
|
||
messages.value = restoredMessages;
|
||
collapsedToolIds.value = nextCollapsedToolIds;
|
||
shouldAutoScroll.value = true;
|
||
};
|
||
|
||
const readUrlApiKey = () => {
|
||
for (const key of PUBLIC_CHAT_API_KEY_QUERY_KEYS) {
|
||
const value = route.query[key];
|
||
if (Array.isArray(value)) {
|
||
const first = String(value[0] || '').trim();
|
||
if (first) return first;
|
||
continue;
|
||
}
|
||
const apiKey = String(value || '').trim();
|
||
if (apiKey) return apiKey;
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const clearUrlApiKey = async () => {
|
||
const nextQuery = {
|
||
...route.query,
|
||
};
|
||
let changed = false;
|
||
for (const key of PUBLIC_CHAT_API_KEY_QUERY_KEYS) {
|
||
if (key in nextQuery) {
|
||
delete nextQuery[key];
|
||
changed = true;
|
||
}
|
||
}
|
||
if (!changed) {
|
||
return;
|
||
}
|
||
await router.replace({
|
||
hash: route.hash,
|
||
path: route.path,
|
||
query: nextQuery,
|
||
});
|
||
};
|
||
|
||
const exchangeApiKeyToAccessToken = async (apiKey: string) => {
|
||
const loginResp = await getResponseBody<PublicChatLoginResult>(
|
||
baseRequestClient.post('/api/v1/auth/loginByApiKey', {
|
||
apiKey,
|
||
}),
|
||
);
|
||
const accessToken = String(loginResp.data?.token || '').trim();
|
||
if (loginResp.errorCode !== 0 || !accessToken) {
|
||
throw new Error(loginResp.message || $t('bot.publicChatTokenInvalid'));
|
||
}
|
||
return accessToken;
|
||
};
|
||
|
||
const extractErrorStatus = (error: any) => {
|
||
const status = Number(error?.response?.status);
|
||
return Number.isFinite(status) ? status : 0;
|
||
};
|
||
|
||
const extractErrorMessage = (error: any) => {
|
||
const responseData = error?.response?.data ?? {};
|
||
const message = String(
|
||
responseData?.message || responseData?.error || error?.message || '',
|
||
).trim();
|
||
return message;
|
||
};
|
||
|
||
const isUnauthorizedError = (error: any) => {
|
||
const responseData = error?.response?.data ?? {};
|
||
return (
|
||
extractErrorStatus(error) === 401 || Number(responseData?.errorCode) === 401
|
||
);
|
||
};
|
||
|
||
const probeAuthenticatedAccess = async (token: string) => {
|
||
if (!token.trim()) {
|
||
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||
}
|
||
try {
|
||
const profileResp = await getResponseBody(
|
||
baseRequestClient.get('/api/v1/sysAccount/myProfile', {
|
||
headers: buildRequestHeaders(token),
|
||
}),
|
||
);
|
||
if (profileResp.errorCode === 0 && profileResp.data) {
|
||
return {
|
||
nextToken: token,
|
||
status: 'authenticated',
|
||
} as AuthResolutionResult;
|
||
}
|
||
if (profileResp.errorCode === 401) {
|
||
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||
}
|
||
return {
|
||
message: normalizePublicChatErrorMessage(profileResp.message),
|
||
status: 'error',
|
||
} as AuthResolutionResult;
|
||
} catch (error) {
|
||
if (isUnauthorizedError(error)) {
|
||
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||
}
|
||
return {
|
||
message: normalizePublicChatErrorMessage(extractErrorMessage(error)),
|
||
status: 'error',
|
||
} as AuthResolutionResult;
|
||
}
|
||
};
|
||
|
||
const tryRefreshCurrentAccessToken = async () => {
|
||
try {
|
||
const refreshed = await refreshTokenApi();
|
||
const nextToken = String(refreshed?.data || '').trim();
|
||
if (!nextToken) {
|
||
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||
}
|
||
accessStore.setAccessToken(nextToken);
|
||
return await probeAuthenticatedAccess(nextToken);
|
||
} catch (error) {
|
||
if (isUnauthorizedError(error)) {
|
||
return { status: 'unauthenticated' } as AuthResolutionResult;
|
||
}
|
||
return {
|
||
message: normalizePublicChatErrorMessage(extractErrorMessage(error)),
|
||
status: 'error',
|
||
} as AuthResolutionResult;
|
||
}
|
||
};
|
||
|
||
const resolveAuthenticatedAccess = async (
|
||
token: string,
|
||
options?: { allowRefresh?: boolean },
|
||
) => {
|
||
const initialResult = await probeAuthenticatedAccess(token);
|
||
if (initialResult.status !== 'unauthenticated' || !options?.allowRefresh) {
|
||
return initialResult;
|
||
}
|
||
return await tryRefreshCurrentAccessToken();
|
||
};
|
||
|
||
const ensureRequestAccessToken = async () => {
|
||
requestAccessToken.value = '';
|
||
authenticatedAccess.value = false;
|
||
const urlApiKey = readUrlApiKey();
|
||
if (urlApiKey) {
|
||
const exchangedToken = await exchangeApiKeyToAccessToken(urlApiKey);
|
||
const authResult = await resolveAuthenticatedAccess(exchangedToken);
|
||
if (authResult.status === 'error') {
|
||
throw new Error(authResult.message || $t('bot.publicChatInitError'));
|
||
}
|
||
if (authResult.status !== 'authenticated') {
|
||
throw new Error($t('bot.publicChatTokenInvalid'));
|
||
}
|
||
requestAccessToken.value = authResult.nextToken || exchangedToken;
|
||
authenticatedAccess.value = true;
|
||
upsertPublicChatContext(
|
||
{
|
||
accessToken: requestAccessToken.value,
|
||
authenticatedAccess: true,
|
||
},
|
||
{ resetConversation: true },
|
||
);
|
||
await clearUrlApiKey();
|
||
return;
|
||
}
|
||
|
||
const storedContext = readPublicChatContext(botId.value);
|
||
if (storedContext?.accessToken) {
|
||
const authResult = await resolveAuthenticatedAccess(
|
||
storedContext.accessToken,
|
||
);
|
||
if (authResult.status === 'error') {
|
||
throw new Error(authResult.message || $t('bot.publicChatInitError'));
|
||
}
|
||
if (authResult.status === 'authenticated') {
|
||
requestAccessToken.value =
|
||
authResult.nextToken || storedContext.accessToken;
|
||
authenticatedAccess.value = true;
|
||
upsertPublicChatContext({
|
||
accessToken: requestAccessToken.value,
|
||
authenticatedAccess: true,
|
||
conversationId: storedContext.conversationId,
|
||
});
|
||
return;
|
||
}
|
||
clearPublicChatContext(botId.value);
|
||
}
|
||
const currentAccessToken = String(accessStore.accessToken || '').trim();
|
||
if (currentAccessToken) {
|
||
const authResult = await resolveAuthenticatedAccess(currentAccessToken, {
|
||
allowRefresh: true,
|
||
});
|
||
if (authResult.status === 'error') {
|
||
throw new Error(authResult.message || $t('bot.publicChatInitError'));
|
||
}
|
||
if (authResult.status === 'authenticated') {
|
||
requestAccessToken.value = authResult.nextToken || currentAccessToken;
|
||
authenticatedAccess.value = true;
|
||
upsertPublicChatContext(
|
||
{
|
||
accessToken: requestAccessToken.value,
|
||
authenticatedAccess: true,
|
||
},
|
||
{ resetConversation: true },
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
const resetConversationState = () => {
|
||
botInfo.value = null;
|
||
conversationId.value = '';
|
||
sending.value = false;
|
||
senderValue.value = '';
|
||
messages.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 === -1 || 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 appendAssistantThinkingDelta = (delta: string) => {
|
||
if (!delta) return;
|
||
const last = getLastAssistantMessage();
|
||
if (!last) return;
|
||
const segments = [...(last.segments || [])];
|
||
const thinkingIndex = [...segments]
|
||
.reverse()
|
||
.findIndex(
|
||
(segment) => segment.type === 'thinking' && segment.status === 'thinking',
|
||
);
|
||
|
||
if (thinkingIndex === -1) {
|
||
segments.push({
|
||
id: uuid(),
|
||
type: 'thinking',
|
||
content: delta,
|
||
expanded: false,
|
||
status: 'thinking',
|
||
});
|
||
} else {
|
||
const actualIndex = segments.length - 1 - thinkingIndex;
|
||
const current = segments[actualIndex] as AssistantThinkingSegment;
|
||
segments[actualIndex] = {
|
||
...current,
|
||
content: `${current.content}${delta}`,
|
||
};
|
||
}
|
||
|
||
updateLastAssistantMessage({
|
||
segments,
|
||
loading: true,
|
||
});
|
||
};
|
||
|
||
const stopAssistantThinking = () => {
|
||
const last = getLastAssistantMessage();
|
||
if (!last?.segments?.length) return;
|
||
updateLastAssistantMessage({
|
||
segments: last.segments.map((segment) => {
|
||
if (segment.type !== 'thinking' || segment.status !== 'thinking') {
|
||
return segment;
|
||
}
|
||
return {
|
||
...segment,
|
||
status: 'end' as ChatThinkingBlockStatus,
|
||
};
|
||
}),
|
||
});
|
||
};
|
||
|
||
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 === 'thinking') {
|
||
return segment.content.trim() !== '';
|
||
}
|
||
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 === -1 ? null : nextToolCalls[idx];
|
||
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 === -1) {
|
||
nextToolCalls.push(next);
|
||
} else {
|
||
nextToolCalls[idx] = 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?.content : payload?.result,
|
||
)
|
||
: 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;
|
||
stopAssistantThinking();
|
||
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;
|
||
stopAssistantThinking();
|
||
updateLastAssistantMessage({
|
||
content: sseData.payload?.message || $t('bot.publicChatInitError'),
|
||
loading: false,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (
|
||
eventDomain === 'TOOL' ||
|
||
eventType === 'TOOL_CALL' ||
|
||
eventType === 'TOOL_RESULT'
|
||
) {
|
||
stopAssistantThinking();
|
||
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'
|
||
) {
|
||
handleNeedSaveToolMessage(role, content);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (eventType === 'MESSAGE' && eventDomain === 'LLM') {
|
||
stopAssistantThinking();
|
||
const deltaRaw = sseData?.payload?.delta ?? sseData?.payload?.content ?? '';
|
||
const delta =
|
||
typeof deltaRaw === 'string'
|
||
? deltaRaw
|
||
: normalizeToolPayloadValue(deltaRaw);
|
||
appendAssistantTextDelta(delta);
|
||
return;
|
||
}
|
||
|
||
if (eventType === 'THINKING' && eventDomain === 'LLM') {
|
||
const deltaRaw = sseData?.payload?.delta ?? sseData?.payload?.content ?? '';
|
||
const delta =
|
||
typeof deltaRaw === 'string'
|
||
? deltaRaw
|
||
: normalizeToolPayloadValue(deltaRaw);
|
||
appendAssistantThinkingDelta(delta);
|
||
return;
|
||
}
|
||
|
||
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;
|
||
|
||
sseClient.post(
|
||
'/api/v1/bot/chat',
|
||
{
|
||
botId: botId.value,
|
||
prompt,
|
||
conversationId: conversationId.value,
|
||
},
|
||
{
|
||
headers: buildRequestHeaders(),
|
||
onMessage: onSseMessage,
|
||
onFinished: () => {
|
||
sending.value = false;
|
||
stopAssistantThinking();
|
||
updateLastAssistantMessage({ loading: false });
|
||
},
|
||
onError: (error: any) => {
|
||
sending.value = false;
|
||
stopAssistantThinking();
|
||
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;
|
||
stopAssistantThinking();
|
||
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 createConversationId = async () => {
|
||
const conversationResp = await getResponseBody<number | string>(
|
||
baseRequestClient.get('/api/v1/bot/generateConversationId', {
|
||
headers: buildRequestHeaders(),
|
||
}),
|
||
);
|
||
if (conversationResp.errorCode !== 0 || !conversationResp.data) {
|
||
throw new Error(conversationResp.message || $t('bot.publicChatInitError'));
|
||
}
|
||
conversationId.value = String(conversationResp.data);
|
||
upsertPublicChatContext({
|
||
accessToken: requestAccessToken.value,
|
||
authenticatedAccess: authenticatedAccess.value,
|
||
conversationId: conversationId.value,
|
||
});
|
||
};
|
||
|
||
const restoreConversationMessages = async () => {
|
||
const storedContext = readPublicChatContext(botId.value);
|
||
const storedConversationId = String(
|
||
storedContext?.conversationId || '',
|
||
).trim();
|
||
if (!storedConversationId) {
|
||
return false;
|
||
}
|
||
|
||
const restoreResp = await getResponseBody<PublicChatSessionRestoreResult>(
|
||
baseRequestClient.get('/api/v1/public-chat/session/restore', {
|
||
params: {
|
||
botId: botId.value,
|
||
conversationId: storedConversationId,
|
||
},
|
||
headers: buildRequestHeaders(),
|
||
}),
|
||
);
|
||
const restoreData = restoreResp.data;
|
||
if (
|
||
restoreResp.errorCode !== 0 ||
|
||
!restoreData?.sessionExists ||
|
||
!restoreData.conversationId
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
conversationId.value = String(restoreData.conversationId);
|
||
restoreMessagesFromRecords(restoreData.messages || []);
|
||
upsertPublicChatContext({
|
||
accessToken: requestAccessToken.value,
|
||
authenticatedAccess: authenticatedAccess.value,
|
||
conversationId: conversationId.value,
|
||
});
|
||
return true;
|
||
};
|
||
|
||
const initPublicChat = async () => {
|
||
resetConversationState();
|
||
initializing.value = true;
|
||
setPageTitle();
|
||
|
||
try {
|
||
await ensureRequestAccessToken();
|
||
|
||
const botResp = await getResponseBody<BotInfo>(
|
||
baseRequestClient.get('/api/v1/bot/getDetail', {
|
||
params: { id: botId.value },
|
||
headers: buildRequestHeaders(),
|
||
}),
|
||
);
|
||
if (botResp.errorCode !== 0 || !botResp.data) {
|
||
throw new Error(botResp.message || $t('bot.publicChatInitError'));
|
||
}
|
||
botInfo.value = botResp.data;
|
||
setPageTitle();
|
||
blocked.value =
|
||
!botResp.data?.options?.anonymousEnabled && !authenticatedAccess.value;
|
||
if (blocked.value) {
|
||
clearPublicChatContext(botId.value);
|
||
return;
|
||
}
|
||
|
||
const restored = await restoreConversationMessages();
|
||
if (!restored) {
|
||
await createConversationId();
|
||
const welcomeMessage = botResp.data?.options?.welcomeMessage;
|
||
if (typeof welcomeMessage === 'string' && welcomeMessage.trim() !== '') {
|
||
appendMessage('assistant', welcomeMessage.trim());
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
initError.value =
|
||
error instanceof Error && error.message
|
||
? normalizePublicChatErrorMessage(error.message)
|
||
: $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"
|
||
:class="[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.publicPageLoginRequired') }}
|
||
</p>
|
||
<p>{{ $t('bot.publicPageLoginRequiredTip') }}</p>
|
||
<ElButton
|
||
type="primary"
|
||
class="public-chat-banner-action"
|
||
@click="goToLogin"
|
||
>
|
||
{{ $t('authentication.goToLogin') }}
|
||
</ElButton>
|
||
</div>
|
||
<template v-else>
|
||
<div
|
||
v-for="item in messages"
|
||
:key="item.id"
|
||
class="public-chat-message-row"
|
||
:class="[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"
|
||
:class="[
|
||
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"
|
||
:class="[
|
||
segment.type === 'tool'
|
||
? 'is-tool'
|
||
: segment.type === 'thinking'
|
||
? 'is-thinking'
|
||
: 'is-text',
|
||
]"
|
||
>
|
||
<template v-if="segment.type === 'text'">
|
||
<ElXMarkdown
|
||
class="public-chat-markdown"
|
||
:markdown="segment.content"
|
||
/>
|
||
</template>
|
||
<template v-else-if="segment.type === 'thinking'">
|
||
<ChatThinkingBlock
|
||
v-model:expanded="segment.expanded"
|
||
:content="segment.content"
|
||
:status="segment.status"
|
||
class="public-chat-thinking-block"
|
||
/>
|
||
</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"
|
||
:class="[
|
||
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"
|
||
v-text="
|
||
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"
|
||
v-text="
|
||
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: #fff;
|
||
--pc-surface-soft: #f8fbff;
|
||
--pc-surface-muted: #f8fafc;
|
||
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
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%);
|
||
isolation: isolate;
|
||
}
|
||
|
||
.public-chat-page::before {
|
||
position: absolute;
|
||
inset: -30% auto auto -20%;
|
||
z-index: -1;
|
||
width: 440px;
|
||
height: 440px;
|
||
pointer-events: none;
|
||
content: '';
|
||
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;
|
||
color: var(--pc-ink-strong);
|
||
letter-spacing: 0.01em;
|
||
}
|
||
|
||
.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-banner-action {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.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 {
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
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: #fff;
|
||
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-thinking {
|
||
display: block;
|
||
}
|
||
|
||
.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%;
|
||
padding: 10px 12px;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
background: linear-gradient(180deg, #fff 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-thinking-block {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.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);
|
||
word-break: break-all;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.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;
|
||
gap: 8px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.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;
|
||
text-overflow: ellipsis;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--pc-ink-strong);
|
||
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: #fff;
|
||
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;
|
||
overflow-wrap: anywhere;
|
||
white-space: pre-wrap;
|
||
background: #fff;
|
||
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;
|
||
margin-bottom: 10px;
|
||
pointer-events: auto;
|
||
background: transparent;
|
||
border-top: 0;
|
||
}
|
||
|
||
.public-chat-preset-item {
|
||
max-width: 100%;
|
||
padding: 8px 14px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
font-size: 12px;
|
||
color: #334155;
|
||
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;
|
||
pointer-events: none;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.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;
|
||
padding: 16px 124px 48px 18px;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
background: rgb(255 255 255);
|
||
border-color: transparent;
|
||
border-radius: 20px;
|
||
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% {
|
||
opacity: 0.5;
|
||
transform: scale(0.75);
|
||
}
|
||
|
||
40% {
|
||
opacity: 1;
|
||
transform: scale(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;
|
||
padding-right: 2px;
|
||
padding-left: 2px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.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>
|