From a6f9fabcece1157cc551eb771033220097e63787 Mon Sep 17 00:00:00 2001 From: Timi Date: Wed, 16 Sep 2026 01:39:44 +0800 Subject: [PATCH] add AIAgent --- src/api/GaoAiAPI.ts | 183 ++++++++ src/composables/useAiChat.ts | 398 ++++++++++++++++++ src/pages/ai/AiChatPage.vue | 42 ++ src/pages/ai/aiChatQuestions.ts | 8 + src/pages/ai/components/AiChatComposer.vue | 131 ++++++ src/pages/ai/components/AiChatMessageList.vue | 314 ++++++++++++++ src/pages/ai/components/AiChatPanel.vue | 124 ++++++ src/pages/tabs/HomeTab.vue | 31 +- src/router/index.ts | 5 + src/router/routes/aiRoutes.ts | 20 + src/utils/package.ts | 15 + 11 files changed, 1266 insertions(+), 5 deletions(-) create mode 100644 src/api/GaoAiAPI.ts create mode 100644 src/composables/useAiChat.ts create mode 100644 src/pages/ai/AiChatPage.vue create mode 100644 src/pages/ai/aiChatQuestions.ts create mode 100644 src/pages/ai/components/AiChatComposer.vue create mode 100644 src/pages/ai/components/AiChatMessageList.vue create mode 100644 src/pages/ai/components/AiChatPanel.vue create mode 100644 src/router/routes/aiRoutes.ts diff --git a/src/api/GaoAiAPI.ts b/src/api/GaoAiAPI.ts new file mode 100644 index 0000000..d892776 --- /dev/null +++ b/src/api/GaoAiAPI.ts @@ -0,0 +1,183 @@ +/** 门店智能助手聊天请求。 */ +export interface GaoAiChatRequest { + message: string; + conversationId?: string; + /** GLOBAL_MANAGER 兼任店长时使用当前选中门店。 */ + storeId?: string; +} + +/** 智能助手 SSE 事件。 */ +export interface GaoAiStreamEvent { + event: string; + data: string; +} + +/** 智能助手 HTTP 错误。 */ +export class GaoAiHttpError extends Error { + readonly status: number; + readonly code?: string; + + constructor(status: number, message: string, code?: string) { + super(message); + this.name = "GaoAiHttpError"; + this.status = status; + this.code = code; + } +} + +/** 智能助手流式响应错误。 */ +export class GaoAiStreamError extends Error { + readonly code?: string; + + constructor(message: string, code?: string) { + super(message); + this.name = "GaoAiStreamError"; + this.code = code; + } +} + +const chatPath = "/gao/ai/chat"; + +/** + * 发起门店智能助手聊天请求。 + * + * @param request 聊天请求 + * @param token 当前登录令牌 + * @param signal 请求取消信号 + */ +export async function chat(request: GaoAiChatRequest, token: string, signal: AbortSignal): Promise { + const apiBaseURL = import.meta.env.VITE_API_BASE_URL as string | undefined; + const url = new URL(chatPath, apiBaseURL || window.location.origin); + const headers: Record = { + Accept: "text/event-stream", + "Content-Type": "application/json" + }; + if (token) { + // 系统现有接口使用 Token;Authorization 兼容智能助手实施协议。 + headers.Authorization = `Bearer ${token}`; + headers.Token = token; + } + + return await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(request), + credentials: "include", + signal + }); +} + +/** + * 读取并解析 SSE 流。 + * + * @param response 聊天响应 + * @param onEvent 事件处理函数 + */ +export async function readSseStream( + response: Response, + onEvent: (event: GaoAiStreamEvent) => Promise +): Promise { + if (!response.body) { + throw new GaoAiStreamError("智能助手没有返回可读取的数据流", "EMPTY_STREAM"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + buffer = await consumeSseBuffer(buffer, onEvent, false); + } + + buffer += decoder.decode(); + await consumeSseBuffer(buffer, onEvent, true); + } finally { + reader.releaseLock(); + } +} + +async function consumeSseBuffer( + buffer: string, + onEvent: (event: GaoAiStreamEvent) => Promise, + isFinal: boolean +): Promise { + const normalizedBuffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n"); + const blockList = normalizedBuffer.split("\n\n"); + const remain = isFinal ? "" : blockList.pop() || ""; + for (const block of blockList) { + const event = parseSseBlock(block); + if (event) { + await onEvent(event); + } + } + if (isFinal && remain.trim()) { + const event = parseSseBlock(remain); + if (event) { + await onEvent(event); + } + } + return remain; +} + +function parseSseBlock(block: string): GaoAiStreamEvent | undefined { + let event = "message"; + const dataLineList: string[] = []; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) { + event = line.slice(6).trim() || "message"; + } else if (line.startsWith("data:")) { + const data = line.slice(5); + dataLineList.push(data.startsWith(" ") ? data.slice(1) : data); + } + } + if (!dataLineList.length) { + return undefined; + } + return { event, data: dataLineList.join("\n") }; +} + +/** 将非 2xx 响应转换为不携带原始响应正文的错误。 */ +export async function ensureChatResponse(response: Response): Promise { + if (response.ok) { + return; + } + + let code: string | undefined; + let message = "智能助手请求失败,请稍后重试"; + try { + const payload = await response.json() as { + code?: unknown; + message?: unknown; + error?: unknown; + }; + if (typeof payload.code === "string") { + code = payload.code; + } + if (typeof payload.message === "string" && payload.message.trim()) { + message = payload.message.trim(); + } else if (typeof payload.error === "string" && payload.error.trim()) { + message = payload.error.trim(); + } + } catch { + // 非 JSON 错误响应使用上面的安全默认文案。 + } + throw new GaoAiHttpError(response.status, message, code); +} + +/** 解析 SSE 数据,解析失败时返回原始文本。 */ +export function parseSseData(data: string): unknown { + if (data === "[DONE]") { + return data; + } + try { + return JSON.parse(data) as unknown; + } catch { + return data; + } +} diff --git a/src/composables/useAiChat.ts b/src/composables/useAiChat.ts new file mode 100644 index 0000000..2f8a2f8 --- /dev/null +++ b/src/composables/useAiChat.ts @@ -0,0 +1,398 @@ +import { computed, onUnmounted, shallowRef, toValue, watch, type MaybeRefOrGetter } from "vue"; +import { useUserStore } from "timi-web-api/store"; +import { + chat, + ensureChatResponse, + GaoAiHttpError, + GaoAiStreamError, + parseSseData, + readSseStream, + type GaoAiStreamEvent +} from "@/api/GaoAiAPI"; + +export type AiMessageRole = "user" | "assistant"; + +export interface AiCitation { + source?: string; + dataDomain?: string; + timeRange?: string; + caliber?: string; + generatedAt?: string; +} + +export interface AiChatMessage { + id: number; + role: AiMessageRole; + content: string; + citationList: AiCitation[]; + createdAt: number; + statusText?: string; + duration?: number; +} + +export interface UseAiChatOptions { + sessionKey: MaybeRefOrGetter; + storeId?: MaybeRefOrGetter; + scrollToBottom?: () => Promise; +} + +const conversationExpiredText = "AI 会话不存在或已过期"; + +/** 管理门店智能助手的会话、流式消息和取消操作。 */ +export function useAiChat(options: UseAiChatOptions) { + const userStore = useUserStore(); + const messages = shallowRef([]); + const conversationId = shallowRef(""); + const isGenerating = shallowRef(false); + const activeToolStatus = shallowRef(""); + const lastError = shallowRef(""); + const lastQuestion = shallowRef(""); + const canRetry = computed(() => !!lastError.value && !!lastQuestion.value && !isGenerating.value); + let activeController: AbortController | undefined; + let messageSequence = 0; + + async function sendMessage(message: string): Promise { + const normalizedMessage = message.trim(); + if (!normalizedMessage || isGenerating.value) { + return; + } + + const userMessage: AiChatMessage = { + id: nextMessageId(), + role: "user", + content: normalizedMessage, + citationList: [], + createdAt: Date.now() + }; + const assistantMessage: AiChatMessage = { + id: nextMessageId(), + role: "assistant", + content: "", + citationList: [], + createdAt: Date.now() + }; + messages.value = [...messages.value, userMessage, assistantMessage]; + lastQuestion.value = normalizedMessage; + lastError.value = ""; + isGenerating.value = true; + activeToolStatus.value = ""; + const controller = new AbortController(); + activeController = controller; + let hasRetriedExpiredConversation = false; + + try { + while (true) { + resetAssistantMessage(assistantMessage.id); + try { + await readChatStream(normalizedMessage, assistantMessage.id, controller); + break; + } catch (error) { + if ( + !hasRetriedExpiredConversation + && !!conversationId.value + && isExpiredConversationError(error) + ) { + hasRetriedExpiredConversation = true; + conversationId.value = ""; + continue; + } + handleChatError(error, assistantMessage.id, controller.signal.aborted); + break; + } + } + } finally { + if (activeController === controller) { + activeController = undefined; + } + isGenerating.value = false; + activeToolStatus.value = ""; + await scrollToBottom(); + } + } + + function stopGeneration(): void { + if (!isGenerating.value) { + return; + } + activeController?.abort(); + activeToolStatus.value = ""; + const assistantMessage = [...messages.value].reverse().find(item => item.role === "assistant"); + if (assistantMessage) { + updateMessage(assistantMessage.id, { statusText: "已停止生成,可重新提问" }); + } + } + + function resetConversation(): void { + activeController?.abort(); + activeController = undefined; + messages.value = []; + conversationId.value = ""; + isGenerating.value = false; + activeToolStatus.value = ""; + lastError.value = ""; + lastQuestion.value = ""; + } + + async function retryLastMessage(): Promise { + if (canRetry.value) { + await sendMessage(lastQuestion.value); + } + } + + async function readChatStream(message: string, assistantId: number, controller: AbortController): Promise { + const storeId = options.storeId ? toValue(options.storeId) : undefined; + const request = { + message, + ...(conversationId.value ? { conversationId: conversationId.value } : {}), + ...(storeId ? { storeId } : {}) + }; + const response = await chat(request, userStore.token, controller.signal); + await ensureChatResponse(response); + let hasDoneEvent = false; + await readSseStream(response, async (event) => { + hasDoneEvent = await handleStreamEvent(event, assistantId, hasDoneEvent); + await options.scrollToBottom?.(); + }); + if (!hasDoneEvent && !controller.signal.aborted) { + throw new GaoAiStreamError("智能助手连接中断,请重试", "DIFY_STREAM_ERROR"); + } + } + + async function handleStreamEvent(event: GaoAiStreamEvent, assistantId: number, hasDoneEvent: boolean): Promise { + const payload = parseSseData(event.data); + switch (event.event) { + case "message": + appendAssistantText(assistantId, extractText(payload)); + return hasDoneEvent; + case "tool_status": + activeToolStatus.value = formatToolStatus(payload); + return hasDoneEvent; + case "citation": + appendCitation(assistantId, normalizeCitation(payload)); + return hasDoneEvent; + case "done": + applyDonePayload(assistantId, payload); + return true; + case "error": + throw createStreamError(payload); + default: + return hasDoneEvent; + } + } + + function applyDonePayload(assistantId: number, payload: unknown): void { + if (!isRecord(payload)) { + return; + } + const nextConversationId = getString(payload.conversationId); + if (nextConversationId) { + conversationId.value = nextConversationId; + } + const duration = getNumber(payload.duration); + updateMessage(assistantId, { + createdAt: Date.now(), + duration: duration === undefined ? undefined : duration + }); + } + + function appendAssistantText(assistantId: number, text: string): void { + if (!text) { + return; + } + const message = messages.value.find(item => item.id === assistantId); + if (!message) { + return; + } + updateMessage(assistantId, { content: message.content + text, statusText: undefined }); + } + + function appendCitation(assistantId: number, citation?: AiCitation): void { + if (!citation) { + return; + } + const message = messages.value.find(item => item.id === assistantId); + if (!message) { + return; + } + updateMessage(assistantId, { citationList: [...message.citationList, citation] }); + } + + function updateMessage(id: number, patch: Partial): void { + messages.value = messages.value.map(item => item.id === id ? { ...item, ...patch } : item); + } + + function resetAssistantMessage(id: number): void { + updateMessage(id, { + content: "", + citationList: [], + statusText: undefined, + duration: undefined, + createdAt: Date.now() + }); + } + + function handleChatError(error: unknown, assistantId: number, isAborted: boolean): void { + if (isAborted) { + return; + } + const message = getChatErrorMessage(error); + lastError.value = message; + updateMessage(assistantId, { statusText: message }); + } + + function clearMessages(): void { + messages.value = []; + } + + function nextMessageId(): number { + messageSequence += 1; + return messageSequence; + } + + async function scrollToBottom(): Promise { + await options.scrollToBottom?.(); + } + + watch(() => toValue(options.sessionKey), () => { + resetConversation(); + }, { flush: "sync" }); + + onUnmounted(() => { + activeController?.abort(); + clearMessages(); + }); + + return { + messages, + isGenerating, + activeToolStatus, + lastError, + canRetry, + sendMessage, + stopGeneration, + resetConversation, + retryLastMessage + }; +} + +function isExpiredConversationError(error: unknown): boolean { + if (error instanceof GaoAiHttpError || error instanceof GaoAiStreamError) { + return error.message.includes(conversationExpiredText) + || error.code?.toLowerCase().includes("session") === true; + } + return false; +} + +function extractText(payload: unknown): string { + if (typeof payload === "string") { + return payload === "[DONE]" ? "" : payload; + } + if (!isRecord(payload)) { + return ""; + } + return getString(payload.answer) || getString(payload.text) || getString(payload.content) || ""; +} + +function formatToolStatus(payload: unknown): string { + if (!isRecord(payload)) { + return "正在查询实时业务数据…"; + } + const status = getString(payload.status); + return status === "finished" ? "实时业务数据查询完成" : "正在查询实时业务数据…"; +} + +function normalizeCitation(payload: unknown): AiCitation | undefined { + if (typeof payload === "string") { + return payload ? { source: payload } : undefined; + } + if (!isRecord(payload)) { + return undefined; + } + const citation: AiCitation = { + source: getString(payload.source), + dataDomain: getString(payload.dataDomain) || getString(payload.domain), + timeRange: getString(payload.timeRange) || formatScope(payload.scope), + caliber: getString(payload.caliber) || getString(payload.metric), + generatedAt: getDisplayText(payload.generatedAt) || getDisplayText(payload.generated_at) + }; + return Object.values(citation).some(Boolean) ? citation : undefined; +} + +function createStreamError(payload: unknown): GaoAiStreamError { + if (!isRecord(payload)) { + return new GaoAiStreamError("智能助手查询失败,请稍后重试", "DIFY_ERROR"); + } + return new GaoAiStreamError( + getString(payload.message) || "智能助手查询失败,请稍后重试", + getString(payload.code) + ); +} + +function getChatErrorMessage(error: unknown): string { + if (error instanceof GaoAiHttpError) { + 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 GaoAiStreamError) { + if (error.code === "DIFY_TIMEOUT") { + return "数据查询超时,请缩小时间范围后重试"; + } + return error.message; + } + if (error instanceof DOMException && error.name === "AbortError") { + return "已停止生成,可重新提问"; + } + if (error instanceof TypeError) { + return "连接中断,可重新提问"; + } + return "智能助手暂时不可用,请稍后重试"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function getDisplayText(value: unknown): string | undefined { + const text = getString(value); + if (text) { + return text; + } + const timestamp = getNumber(value); + if (timestamp === undefined) { + return undefined; + } + const milliseconds = timestamp < 100000000000 ? timestamp * 1000 : timestamp; + return new Date(milliseconds).toLocaleString("zh-CN", { hour12: false }); +} + +function formatScope(value: unknown): string | undefined { + const text = getString(value); + if (text) { + return text; + } + if (!isRecord(value)) { + return undefined; + } + const beginAt = getDisplayText(value.beginAt) || getDisplayText(value.begin); + const endAt = getDisplayText(value.endAt) || getDisplayText(value.end); + if (beginAt && endAt) { + return `${beginAt} 至 ${endAt}`; + } + return beginAt || endAt; +} + +function getNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} diff --git a/src/pages/ai/AiChatPage.vue b/src/pages/ai/AiChatPage.vue new file mode 100644 index 0000000..ce4beb3 --- /dev/null +++ b/src/pages/ai/AiChatPage.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/pages/ai/aiChatQuestions.ts b/src/pages/ai/aiChatQuestions.ts new file mode 100644 index 0000000..48ae653 --- /dev/null +++ b/src/pages/ai/aiChatQuestions.ts @@ -0,0 +1,8 @@ +/** 门店智能助手快捷问题。 */ +export const aiQuickQuestionList = [ + "今天门店经营怎么样?", + "本月销售额、订单数和应收款是多少?", + "哪些产品库存需要关注?", + "本月有哪些需要重点跟进的客户?", + "本月客户复购情况如何?" +] as const; diff --git a/src/pages/ai/components/AiChatComposer.vue b/src/pages/ai/components/AiChatComposer.vue new file mode 100644 index 0000000..0f50029 --- /dev/null +++ b/src/pages/ai/components/AiChatComposer.vue @@ -0,0 +1,131 @@ + + + diff --git a/src/pages/ai/components/AiChatMessageList.vue b/src/pages/ai/components/AiChatMessageList.vue new file mode 100644 index 0000000..2cff907 --- /dev/null +++ b/src/pages/ai/components/AiChatMessageList.vue @@ -0,0 +1,314 @@ + + + diff --git a/src/pages/ai/components/AiChatPanel.vue b/src/pages/ai/components/AiChatPanel.vue new file mode 100644 index 0000000..d53bbce --- /dev/null +++ b/src/pages/ai/components/AiChatPanel.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/pages/tabs/HomeTab.vue b/src/pages/tabs/HomeTab.vue index 31fe26d..c8a7dac 100644 --- a/src/pages/tabs/HomeTab.vue +++ b/src/pages/tabs/HomeTab.vue @@ -39,6 +39,18 @@ + + + @@ -46,7 +58,7 @@