Files
EasyFlow/easyflow-ui-admin/app/src/views/publicChat/index.vue
陈子默 ba70fec9a5 fix: 优化公开聊天页登录态恢复与鉴权引导
- 支持复用现有登录态并恢复 refresh token

- 未认证访问时补充跳转登录提示与引导文案
2026-05-06 19:22:21 +08:00

2179 lines
56 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { 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>