434 lines
12 KiB
Vue
434 lines
12 KiB
Vue
<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>
|