diff --git a/src/api/GaoAiAPI.ts b/src/api/GaoAiAPI.ts index d892776..4557eea 100644 --- a/src/api/GaoAiAPI.ts +++ b/src/api/GaoAiAPI.ts @@ -1,3 +1,5 @@ +import { axios } from "timi-web-api"; + /** 门店智能助手聊天请求。 */ export interface GaoAiChatRequest { message: string; @@ -6,6 +8,58 @@ export interface GaoAiChatRequest { storeId?: string; } +/** 历史会话列表排序方式。 */ +export type GaoAiConversationSortBy = "created_at" | "-created_at" | "updated_at" | "-updated_at"; + +/** 历史会话列表请求。 */ +export interface GaoAiConversationListRequest { + lastId?: string; + limit?: number; + sortBy?: GaoAiConversationSortBy; +} + +/** 历史会话。 */ +export interface GaoAiConversation { + id: string; + name?: string; + status?: string; + introduction?: string; + "created_at"?: number | string; + "updated_at"?: number | string; +} + +/** 历史会话列表响应。 */ +export interface GaoAiConversationListResponse { + "has_more"?: boolean; + limit?: number; + data?: GaoAiConversation[]; +} + +/** 历史会话消息读取请求。 */ +export interface GaoAiConversationReadRequest { + conversationId: string; + firstId?: string; + limit?: number; +} + +/** 历史会话消息。 */ +export interface GaoAiConversationMessage { + id: string; + "conversation_id"?: string; + query?: string; + answer?: string; + "created_at"?: number | string; + status?: string; + feedback?: unknown; +} + +/** 历史会话消息列表响应。 */ +export interface GaoAiConversationReadResponse { + "has_more"?: boolean; + limit?: number; + data?: GaoAiConversationMessage[]; +} + /** 智能助手 SSE 事件。 */ export interface GaoAiStreamEvent { event: string; @@ -37,6 +91,40 @@ export class GaoAiStreamError extends Error { } const chatPath = "/gao/ai/chat"; +const conversationBasePath = "/gao/ai/conversation"; + +/** + * 查询当前登录用户的历史会话。 + * + * @param request 历史会话分页请求 + */ +export async function listConversations( + request: GaoAiConversationListRequest = {} +): Promise { + return await axios.post(`${conversationBasePath}/list`, request); +} + +/** + * 读取指定历史会话的消息。 + * + * @param request 历史会话读取请求 + */ +export async function readConversation( + request: GaoAiConversationReadRequest +): Promise { + return await axios.post(`${conversationBasePath}/read`, request); +} + +/** + * 删除指定历史会话。 + * + * @param conversationId 会话 ID + */ +export async function deleteConversation(conversationId: string): Promise { + await axios.post(`${conversationBasePath}/delete`, undefined, { + params: { conversationId } + }); +} /** * 发起门店智能助手聊天请求。 diff --git a/src/assets/styles/global.less b/src/assets/styles/global.less index cf4007b..45f6594 100644 --- a/src/assets/styles/global.less +++ b/src/assets/styles/global.less @@ -92,6 +92,11 @@ html.theme-dark body { gap: 1rem; } +.t-pull-down-refresh { + overflow-x: hidden; + overflow-y: auto; +} + .t-dropdown-menu { &:after { @@ -111,9 +116,10 @@ html.theme-dark body { .center-tips { color: var(--tui-gray); + margin: 0 auto; padding: .75rem; - text-align: center; font-size: .875rem; + text-align: center; } .avatar { diff --git a/src/composables/useAiChat.ts b/src/composables/useAiChat.ts index 0d62323..5d70382 100644 --- a/src/composables/useAiChat.ts +++ b/src/composables/useAiChat.ts @@ -6,7 +6,10 @@ import { GaoAiHttpError, GaoAiStreamError, parseSseData, + readConversation as readConversationApi, readSseStream, + type GaoAiConversationMessage, + type GaoAiConversationReadResponse, type GaoAiStreamEvent } from "@/api/GaoAiAPI"; @@ -58,17 +61,18 @@ export function useAiChat(options: UseAiChatOptions) { const messages = shallowRef([]); const conversationId = shallowRef(""); const isGenerating = shallowRef(false); + const isLoadingConversation = shallowRef(false); const activeToolStatus = shallowRef(""); const lastError = shallowRef(""); const lastQuestion = shallowRef(""); - const canRetry = computed(() => !!lastError.value && !!lastQuestion.value && !isGenerating.value); + const canRetry = computed(() => !!lastError.value && !!lastQuestion.value && !isGenerating.value && !isLoadingConversation.value); let activeController: AbortController | undefined; let messageSequence = 0; const responseStateMap = new Map(); async function sendMessage(message: string): Promise { const normalizedMessage = message.trim(); - if (!normalizedMessage || isGenerating.value) { + if (!normalizedMessage || isGenerating.value || isLoadingConversation.value) { return; } @@ -152,6 +156,70 @@ export function useAiChat(options: UseAiChatOptions) { lastQuestion.value = ""; } + /** + * 读取历史会话并转换成当前聊天页面使用的消息模型。 + * + * @param id 历史会话 ID + */ + async function loadConversation(id: string): Promise { + const normalizedId = id.trim(); + if (!normalizedId || isGenerating.value || isLoadingConversation.value) { + return; + } + + isLoadingConversation.value = true; + messages.value = []; + responseStateMap.clear(); + conversationId.value = normalizedId; + lastError.value = ""; + lastQuestion.value = ""; + try { + const result = await readConversationHistory(normalizedId); + if (conversationId.value !== normalizedId) { + return; + } + messages.value = mapConversationMessages(result); + lastQuestion.value = [...messages.value].reverse().find(item => item.role === "user")?.content || ""; + await scrollToBottom(); + } catch (error) { + if (conversationId.value === normalizedId) { + resetConversation(); + } + throw error; + } finally { + isLoadingConversation.value = false; + } + } + + async function readConversationHistory(id: string): Promise { + 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 + }; + } + async function retryLastMessage(): Promise { if (canRetry.value) { await sendMessage(lastQuestion.value); @@ -330,6 +398,51 @@ export function useAiChat(options: UseAiChatOptions) { return thinkingSequence; } + function mapConversationMessages(response: GaoAiConversationReadResponse): AiChatMessage[] { + const sourceList = Array.isArray(response.data) ? response.data : []; + const orderedList = sourceList + .map((item, index) => ({ + item, + index, + timestamp: getTimestamp(item["created_at"]) + })) + .sort((left, right) => left.timestamp - right.timestamp || left.index - right.index) + .map(item => item.item); + const messageList: AiChatMessage[] = []; + + for (const item of orderedList) { + const createdAt = getTimestamp(item["created_at"]); + const query = getRawString(item.query); + const answer = getRawString(item.answer); + if (query) { + messageList.push({ + id: nextMessageId(), + role: "user", + content: query, + thinkingList: [], + citationList: [], + createdAt + }); + } + if (answer || isFailedConversationMessage(item)) { + const parsedAnswer = parseAssistantResponse(answer); + messageList.push({ + id: nextMessageId(), + role: "assistant", + content: parsedAnswer.answer, + thinkingList: parsedAnswer.thinkingList.map(content => ({ + id: nextThinkingId(), + content + })), + citationList: [], + createdAt, + statusText: isFailedConversationMessage(item) ? "本次回答生成失败" : undefined + }); + } + } + return messageList; + } + async function scrollToBottom(): Promise { await options.scrollToBottom?.(); } @@ -345,11 +458,14 @@ export function useAiChat(options: UseAiChatOptions) { return { messages, + conversationId: computed(() => conversationId.value), isGenerating, + isLoadingConversation, activeToolStatus, lastError, canRetry, sendMessage, + loadConversation, stopGeneration, resetConversation, retryLastMessage @@ -565,3 +681,16 @@ function formatScope(value: unknown): string | undefined { function getNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } + +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 isFailedConversationMessage(message: GaoAiConversationMessage): boolean { + const status = getString(message.status)?.toLowerCase(); + return status === "error" || status === "failed" || status === "failure"; +} diff --git a/src/pages/ai/components/AiChatComposer.vue b/src/pages/ai/components/AiChatComposer.vue index 2541476..4f3fcc7 100644 --- a/src/pages/ai/components/AiChatComposer.vue +++ b/src/pages/ai/components/AiChatComposer.vue @@ -9,7 +9,7 @@ v-model="draft" class="input" borderless - :disabled="isGenerating" + :disabled="isGenerating || props.disabled" :maxlength="256" placeholder="问问小糕怎么个事…" /> @@ -43,9 +43,10 @@ interface Props { isGenerating: boolean; lastError: string; canRetry: boolean; + disabled?: boolean; } -defineProps(); +const props = defineProps(); const draft = defineModel({ required: true }); const emit = defineEmits<{ send: [message: string]; @@ -54,6 +55,9 @@ const emit = defineEmits<{ }>(); function submitMessage(): void { + if (props.disabled) { + return; + } const message = draft.value.trim(); if (!message) { return; @@ -98,7 +102,7 @@ function submitMessage(): void { padding: .25rem .25rem .25rem .75rem; box-shadow: 0 .5rem 1.5rem rgba(15, 23, 42, .12); align-items: center; - border-radius: 1rem; + border-radius: 1.25rem; .input { min-width: 0; diff --git a/src/pages/ai/components/AiChatPanel.vue b/src/pages/ai/components/AiChatPanel.vue index d53bbce..b7c8301 100644 --- a/src/pages/ai/components/AiChatPanel.vue +++ b/src/pages/ai/components/AiChatPanel.vue @@ -1,5 +1,5 @@