Files
gao-customer-h5/src/pages/ai/components/AiChatPanel.vue
T
2026-09-22 15:40:21 +08:00

434 lines
12 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.
<template>
<shared-ai-chat-panel
ref="messageList"
class="shared-panel"
:messages="messages"
:is-generating="isGenerating"
:is-loading="isLoadingConversation"
:active-tool-status="activeToolStatus"
:last-error="lastError"
:can-retry="canRetry"
:aria-label="`${props.storeName || '当前会话'}经营助手`"
:role-names="{ assistant: 'AI 小糕', user: '店长' }"
:placeholder="'问问小糕怎么个事…'"
:format-message-status="formatMessageStatus"
@send="submitMessage"
@stop="stopGeneration"
@retry="retryLastMessage"
@new-conversation="emit('new-conversation')"
>
<template #welcome>
<div class="welcome">
<div class="welcome-icon" v-entrance-transition>
<t-icon name="robot-2" size="2rem" />
</div>
<h3 v-entrance-transition="70">今天想了解什么?</h3>
<p v-entrance-transition="140">我会根据当前门店的实时业务数据回答经营问题。</p>
<div class="quick-list" v-entrance-transition="210">
<t-button
class="quick-question bg-white"
v-for="question in aiQuickQuestionList"
:key="question"
size="small"
@click="submitMessage(question)"
>
<span v-text="question" />
</t-button>
</div>
<t-button
v-if="aiQuickQuestionList.length"
class="refresh-button"
variant="text"
size="small"
:disabled="isRefreshingQuestions"
@click="refreshQuickQuestions"
>
<template #icon>
<t-icon name="refresh" class="refresh-icon" :class="{ refreshing: isRefreshingQuestions }" />
</template>
<span>换一批</span>
</t-button>
</div>
</template>
<template #citation="{ citation }">
<div class="citation">
<span class="citation-label">数据来源</span>
<span v-text="formatCitation(citation)" />
<span v-if="citation.generatedAt" class="citation-time">
<span v-text="`生成时间:${citation.generatedAt}`" />
</span>
</div>
</template>
</shared-ai-chat-panel>
<ai-conversation-list
v-model:visible="conversationVisible"
:active-conversation-id="conversationId"
:disabled="isBusy"
:session-key="props.sessionKey"
@select="selectConversation"
@delete="handleConversationDelete"
/>
</template>
<script setup lang="ts">
import {
AiChatHttpError,
AiChatPanel as SharedAiChatPanel,
AiChatStreamError,
parseAssistantResponse,
useAiChat,
type AiChatCitationFormatter,
type AiChatMessage,
type AiChatMessageStatusFormatter
} from "timi-tdesign-mobile";
import type { GaoAiConversation, GaoAiConversationMessage, GaoAiConversationReadResponse } from "@/api/GaoAiAPI";
import {
chat,
readConversation as readConversationApi
} from "@/api/GaoAiAPI";
import AiConversationList from "@/pages/ai/components/AiConversationList.vue";
import { aiQuickQuestionList, loadAiQuickQuestions, refreshAiQuickQuestions } from "@/pages/ai/aiChatQuestions";
import { useNavBarStore } from "@/store/navBarStore";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { useUserStore } from "timi-web-api/store";
defineOptions({
name: "AiChatPanel"
});
interface Props {
storeName: string;
sessionKey: string;
storeId?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
"new-conversation": [];
}>();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
type MessageListExpose = {
scrollToBottom: () => Promise<void>;
};
const messageListRef = useTemplateRef<MessageListExpose>("messageList");
const conversationVisible = ref(false);
const isRefreshingQuestions = ref(false);
const {
messages,
conversationId,
isGenerating,
isLoadingConversation,
activeToolStatus,
lastError,
canRetry,
sendMessage,
loadConversation,
stopGeneration,
retryLastMessage,
resetConversation
} = useAiChat<GaoAiConversationReadResponse, { storeId?: string }>({
sessionKey: () => props.sessionKey,
context: () => ({ storeId: props.storeId }),
request: async ({ message, conversationId: activeConversationId, context, signal }) => await chat({
message,
...(activeConversationId ? { conversationId: activeConversationId } : {}),
...(context?.storeId ? { storeId: context.storeId } : {})
}, userStore.token, signal),
readConversation: async conversationIdValue => await readConversationHistory(conversationIdValue),
mapConversationMessages,
scrollToBottom,
isConversationExpired,
getErrorMessage: error => getAiErrorMessage(error)
});
const isBusy = computed(() => isGenerating.value || isLoadingConversation.value);
const formatCitation: AiChatCitationFormatter = citation => [
citation.dataDomain ? `数据域:${citation.dataDomain}` : "",
citation.timeRange ? `统计范围:${citation.timeRange}` : "",
citation.caliber ? `统计口径:${citation.caliber}` : "",
citation.source ? `来源:${citation.source}` : ""
].filter(Boolean).join(" · ");
const formatMessageStatus: AiChatMessageStatusFormatter = message => {
if (message.statusText) {
return message.statusText;
}
const timeText = `生成时间:${new Date(message.createdAt).toLocaleString("zh-CN", { hour12: false })}`;
if (message.duration !== undefined) {
const durationText = message.duration < 1000
? `${message.duration} 毫秒`
: `${(message.duration / 1000).toFixed(1)} 秒`;
return `${timeText} · 查询耗时:${durationText}`;
}
return timeText;
};
async function submitMessage(message: string): Promise<void> {
await sendMessage(message);
}
function openConversationList(): void {
if (!isBusy.value) {
conversationVisible.value = true;
}
}
async function selectConversation(conversation: GaoAiConversation): Promise<void> {
if (isBusy.value || !conversation.id) {
return;
}
try {
await loadConversation(conversation.id);
} catch (error) {
Message.error(getErrorMessage(error, "历史会话读取失败"));
}
}
function handleConversationDelete(deletedConversationId: string): void {
if (deletedConversationId === conversationId.value) {
resetConversation();
}
}
async function refreshQuickQuestions(): Promise<void> {
if (isRefreshingQuestions.value) {
return;
}
isRefreshingQuestions.value = true;
refreshAiQuickQuestions();
await new Promise<void>(resolve => setTimeout(resolve, 300));
isRefreshingQuestions.value = false;
}
async function scrollToBottom(): Promise<void> {
await messageListRef.value?.scrollToBottom();
}
async function readConversationHistory(id: string): Promise<GaoAiConversationReadResponse> {
const messageList: GaoAiConversationMessage[] = [];
let firstId: string | undefined;
let result: GaoAiConversationReadResponse = {};
while (true) {
result = await readConversationApi({
conversationId: id,
firstId,
limit: 100
});
const currentList = Array.isArray(result.data) ? result.data : [];
messageList.push(...currentList);
if (result["has_more"] !== true || !currentList.length) {
break;
}
const nextFirstId = currentList[0]?.id;
if (!nextFirstId || nextFirstId === firstId) {
break;
}
firstId = nextFirstId;
}
return {
...result,
data: messageList
};
}
function mapConversationMessages(response: GaoAiConversationReadResponse): AiChatMessage[] {
const sourceList = Array.isArray(response.data) ? response.data : [];
return sourceList
.map((item, index) => ({
item,
index,
timestamp: getTimestamp(item["created_at"])
}))
.sort((left, right) => left.timestamp - right.timestamp || left.index - right.index)
.flatMap(({ item, index }) => {
const messageList: AiChatMessage[] = [];
const createdAt = getTimestamp(item["created_at"]);
const query = getRawString(item.query);
const answer = getRawString(item.answer);
if (query) {
messageList.push(createHistoryMessage(index * 2 + 1, "user", query, createdAt));
}
if (answer || isFailedConversationMessage(item)) {
const parsedAnswer = parseAssistantResponse(answer);
messageList.push({
...createHistoryMessage(index * 2 + 2, "assistant", parsedAnswer.answer, createdAt),
thinkingList: parsedAnswer.thinkingList.map((content, index) => ({
id: index + 1,
content
})),
statusText: isFailedConversationMessage(item) ? "本次回答生成失败" : undefined
});
}
return messageList;
});
}
function createHistoryMessage(
id: number,
role: AiChatMessage["role"],
content: string,
createdAt: number
): AiChatMessage {
return {
id,
role,
content,
thinkingList: [],
citationList: [],
createdAt
};
}
function isConversationExpired(error: unknown): boolean {
if (error instanceof AiChatHttpError || error instanceof AiChatStreamError) {
return error.message.includes("AI 会话不存在或已过期")
|| error.code?.toLowerCase().includes("session") === true;
}
return false;
}
function getAiErrorMessage(error: unknown): string {
if (error instanceof AiChatHttpError) {
if (error.status === 401) {
return error.message.includes("会话") ? error.message : "登录已过期,请重新登录";
}
if (error.status === 403) {
return "当前账号无权使用门店智能助手";
}
if (error.status === 429) {
return "请求过于频繁,请稍后再试";
}
return error.message;
}
if (error instanceof AiChatStreamError) {
if (error.code === "DIFY_TIMEOUT") {
return "数据查询超时,请缩小时间范围后重试";
}
return error.message;
}
return getErrorMessage(error, "智能助手暂时不可用,请稍后重试");
}
function getTimestamp(value: number | string | undefined): number {
const timestamp = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return Date.now();
}
return timestamp < 100000000000 ? timestamp * 1000 : timestamp;
}
function getRawString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function isFailedConversationMessage(message: GaoAiConversationMessage): boolean {
const status = typeof message.status === "string" ? message.status.toLowerCase() : "";
return status === "error" || status === "failed" || status === "failure";
}
onMounted(async () => {
navBarStore.clearRight();
navBarStore.setRightText("历史", "var(--td-brand-color)", openConversationList);
await loadAiQuickQuestions();
});
watch(isBusy, value => navBarStore.setRightDisabled(value));
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.shared-panel {
padding-top: var(--app-nav-height);
:deep(.welcome) {
margin: auto 0;
text-align: center;
.welcome-icon {
color: #0f766e;
width: 4rem;
height: 4rem;
margin: 0 auto 1rem;
border-radius: 1.25rem;
display: flex;
background: #ccfbf1;
align-items: center;
justify-content: center;
}
h3 {
margin: 0;
font-size: 1.125rem;
}
p {
color: var(--td-text-color-secondary);
margin: .5rem auto 1rem;
font-size: .8125rem;
line-height: 1.5;
}
.quick-list {
gap: .5rem;
display: flex;
flex-direction: column;
justify-content: center;
.quick-question {
overflow: hidden;
min-width: 0;
max-width: 100%;
:deep(.t-button__content) {
display: block;
overflow: hidden;
min-width: 0;
max-width: 100%;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.refresh-button {
margin-top: .75rem;
.refresh-icon.refreshing {
animation: refresh-once .3s var(--td-animation-easing-standard);
}
}
}
:deep(.citation) {
color: var(--td-text-color-secondary);
display: flex;
font-size: .6875rem;
line-height: 1.4;
flex-wrap: wrap;
}
:deep(.citation-label) {
color: #0f766e;
margin-right: .25rem;
}
:deep(.citation-time) {
width: 100%;
margin-top: .125rem;
}
}
@keyframes refresh-once {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
</style>