Files
EasyFlow/easyflow-ui-admin/app/src/views/publicChat/index.vue
陈子默 7e7c236c2a fix: 修复管理端前端 lint 与构建问题
- 收敛 easyflow-ui-admin 的 lint、格式和类型问题

- 修正 demo 页面与管理端前端构建失败点

- 验证 pnpm lint 与 pnpm build 均已通过
2026-04-05 21:39:13 +08:00

2050 lines
52 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 { IconifyIcon } from '@easyflow/icons';
import { $t } from '@easyflow/locales';
import { uuid } from '@easyflow/utils';
import { useTitle } from '@vueuse/core';
import { ElAvatar, ElButton, ElInput, ElSkeleton } from 'element-plus';
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[];
}
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 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 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 resolveAuthenticatedAccess = async (token: string) => {
try {
const profileResp = await getResponseBody(
baseRequestClient.get('/api/v1/sysAccount/myProfile', {
headers: buildRequestHeaders(token),
}),
);
return profileResp.errorCode === 0 && !!profileResp.data;
} catch {
return false;
}
};
const ensureRequestAccessToken = async () => {
requestAccessToken.value = '';
authenticatedAccess.value = false;
const urlApiKey = readUrlApiKey();
if (urlApiKey) {
const exchangedToken = await exchangeApiKeyToAccessToken(urlApiKey);
const authenticated = await resolveAuthenticatedAccess(exchangedToken);
if (!authenticated) {
throw new Error($t('bot.publicChatTokenInvalid'));
}
requestAccessToken.value = exchangedToken;
authenticatedAccess.value = true;
upsertPublicChatContext(
{
accessToken: exchangedToken,
authenticatedAccess: true,
},
{ resetConversation: true },
);
await clearUrlApiKey();
return;
}
const storedContext = readPublicChatContext(botId.value);
if (storedContext?.accessToken) {
const tokenValid = await resolveAuthenticatedAccess(
storedContext.accessToken,
);
if (tokenValid) {
requestAccessToken.value = storedContext.accessToken;
authenticatedAccess.value = storedContext.authenticatedAccess;
upsertPublicChatContext({
accessToken: storedContext.accessToken,
authenticatedAccess: storedContext.authenticatedAccess,
conversationId: storedContext.conversationId,
});
return;
}
clearPublicChatContext(botId.value);
}
const tokenResp = await getResponseBody<string>(
baseRequestClient.get('/api/temp-token/create'),
);
if (tokenResp.errorCode !== 0 || !tokenResp.data) {
throw new Error($t('bot.publicChatInitError'));
}
requestAccessToken.value = String(tokenResp.data);
upsertPublicChatContext(
{
accessToken: requestAccessToken.value,
authenticatedAccess: false,
},
{ 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
? 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.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"
: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-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>