Files
EasyFlow/easyflow-ui-admin/app/src/views/publicChat/index.vue
陈子默 cac0fdf858 refactor: 调整聊天组件为按需引入
- 移除 bootstrap 中 vue-element-plus-x 全局组件注册

- 在聊天与会话页面改为局部引入对应组件
2026-03-11 22:06:14 +08:00

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