update AIChart, customer followup, media selector

This commit is contained in:
Timi
2026-09-18 17:38:55 +08:00
parent c6b398fa98
commit 57c4275a23
57 changed files with 3866 additions and 2071 deletions
+316 -74
View File
@@ -1,30 +1,63 @@
<template>
<section class="ai-chat-panel" :aria-label="`${props.storeName || '当前门店'}经营助手`">
<ai-chat-message-list
ref="messageList"
:messages="messages"
:is-generating="isGenerating"
:quick-question-list="aiQuickQuestionList"
@select-question="submitMessage"
@refresh-questions="refreshQuickQuestions"
/>
<div class="bottom-area">
<div v-if="activeToolStatus" class="tool-status">
<t-icon name="loading" size="1rem" />
<span v-text="activeToolStatus" />
<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"
>
<template #welcome>
<div class="welcome">
<div class="welcome-icon"><t-icon name="robot-2" size="2rem" /></div>
<h3>今天想了解什么?</h3>
<p>我会根据当前门店的实时业务数据回答经营问题。</p>
<div class="quick-list">
<t-button
v-for="question in aiQuickQuestionList"
:key="question"
class="quick-question"
variant="outline"
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>
<ai-chat-composer
v-model="draft"
:is-generating="isGenerating"
:disabled="isLoadingConversation"
:last-error="lastError"
:can-retry="canRetry"
@send="submitMessage"
@stop="stopGeneration"
@retry="retryLastMessage"
/>
</div>
</section>
</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"
@@ -34,16 +67,29 @@
@delete="handleConversationDelete"
/>
</template>
<script setup lang="ts">
import { useAiChat } from "@/composables/useAiChat";
import type { GaoAiConversation } from "@/api/GaoAiAPI";
import AiChatComposer from "@/pages/ai/components/AiChatComposer.vue";
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 AiChatMessageList from "@/pages/ai/components/AiChatMessageList.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"
@@ -56,14 +102,15 @@ interface Props {
}
const props = defineProps<Props>();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
type MessageListExpose = {
scrollToBottom: () => Promise<void>;
};
const messageListRef = useTemplateRef<MessageListExpose>("messageList");
const draft = shallowRef("");
const conversationVisible = ref(false);
const isRefreshingQuestions = ref(false);
const {
messages,
conversationId,
@@ -77,12 +124,40 @@ const {
stopGeneration,
retryLastMessage,
resetConversation
} = useAiChat({
} = useAiChat<GaoAiConversationReadResponse, { storeId?: string }>({
sessionKey: () => props.sessionKey,
storeId: () => props.storeId,
scrollToBottom
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);
@@ -111,14 +186,144 @@ function handleConversationDelete(deletedConversationId: string): void {
}
}
function refreshQuickQuestions(): void {
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);
@@ -129,60 +334,97 @@ watch(isBusy, value => navBarStore.setRightDisabled(value));
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.ai-chat-panel {
height: 100%;
display: flex;
position: relative;
overflow: hidden;
min-height: 0;
background: var(--ai-background);
padding-top: var(--app-nav-height);
flex-direction: column;
.shared-panel {
:deep(.ai-chat-panel) {
padding-top: var(--app-nav-height);
}
.bottom-area {
inset: auto 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
width: auto;
margin: 0 auto;
z-index: 2;
position: absolute;
max-width: 40rem;
:deep(.welcome) {
margin: auto 0;
text-align: center;
.tool-status {
gap: .375rem;
margin-bottom: .5rem;
.welcome-icon {
color: #0f766e;
padding: .5rem .75rem;
border-radius: .75rem;
width: 4rem;
height: 4rem;
margin: 0 auto 1rem;
border-radius: 1.25rem;
display: flex;
font-size: .75rem;
background: var(--ai-soft);
background: #ccfbf1;
align-items: center;
justify-content: center;
}
.t-icon {
animation: rotate 1s linear infinite;
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 rotate {
@keyframes refresh-once {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
:global(:root) {
--ai-background: rgba(255, 255, 255, .98);
--ai-bubble: #f5f6f8;
--ai-input: white;
--ai-soft: #f0fdfa;
}
:global(html.theme-dark) {
--ai-background: rgba(15, 23, 42, .98);
--ai-bubble: #1e293b;
--ai-input: #111827;
--ai-soft: rgba(20, 184, 166, .12);
}
</style>