diff --git a/src/composables/useAiChat.ts b/src/composables/useAiChat.ts index 2f8a2f8..0d62323 100644 --- a/src/composables/useAiChat.ts +++ b/src/composables/useAiChat.ts @@ -20,10 +20,16 @@ export interface AiCitation { generatedAt?: string; } +export interface AiThinking { + id: number; + content: string; +} + export interface AiChatMessage { id: number; role: AiMessageRole; content: string; + thinkingList: AiThinking[]; citationList: AiCitation[]; createdAt: number; statusText?: string; @@ -37,6 +43,14 @@ export interface UseAiChatOptions { } const conversationExpiredText = "AI 会话不存在或已过期"; +const thinkOpenMarkers = ["", ""]; +const thinkCloseMarkers = ["", ""]; + +interface AssistantResponseState { + rawText: string; + eventThinkingList: AiThinking[]; + taggedThinkingList: AiThinking[]; +} /** 管理门店智能助手的会话、流式消息和取消操作。 */ export function useAiChat(options: UseAiChatOptions) { @@ -50,6 +64,7 @@ export function useAiChat(options: UseAiChatOptions) { const canRetry = computed(() => !!lastError.value && !!lastQuestion.value && !isGenerating.value); let activeController: AbortController | undefined; let messageSequence = 0; + const responseStateMap = new Map(); async function sendMessage(message: string): Promise { const normalizedMessage = message.trim(); @@ -61,6 +76,7 @@ export function useAiChat(options: UseAiChatOptions) { id: nextMessageId(), role: "user", content: normalizedMessage, + thinkingList: [], citationList: [], createdAt: Date.now() }; @@ -68,6 +84,7 @@ export function useAiChat(options: UseAiChatOptions) { id: nextMessageId(), role: "assistant", content: "", + thinkingList: [], citationList: [], createdAt: Date.now() }; @@ -106,6 +123,7 @@ export function useAiChat(options: UseAiChatOptions) { } isGenerating.value = false; activeToolStatus.value = ""; + responseStateMap.delete(assistantMessage.id); await scrollToBottom(); } } @@ -126,6 +144,7 @@ export function useAiChat(options: UseAiChatOptions) { activeController?.abort(); activeController = undefined; messages.value = []; + responseStateMap.clear(); conversationId.value = ""; isGenerating.value = false; activeToolStatus.value = ""; @@ -161,11 +180,20 @@ export function useAiChat(options: UseAiChatOptions) { async function handleStreamEvent(event: GaoAiStreamEvent, assistantId: number, hasDoneEvent: boolean): Promise { const payload = parseSseData(event.data); switch (event.event) { + case "think": + case "thinking": + appendAssistantThinking(assistantId, extractThinkingText(payload)); + return hasDoneEvent; + case "agent_thought": + appendAssistantThinking(assistantId, extractThinkingText(payload), true); + return hasDoneEvent; case "message": - appendAssistantText(assistantId, extractText(payload)); + appendAssistantResponse(assistantId, extractText(payload)); return hasDoneEvent; case "tool_status": - activeToolStatus.value = formatToolStatus(payload); + { + activeToolStatus.value = formatToolStatus(payload); + } return hasDoneEvent; case "citation": appendCitation(assistantId, normalizeCitation(payload)); @@ -195,15 +223,58 @@ export function useAiChat(options: UseAiChatOptions) { }); } - function appendAssistantText(assistantId: number, text: string): void { + function appendAssistantResponse(assistantId: number, text: string): void { if (!text) { return; } - const message = messages.value.find(item => item.id === assistantId); - if (!message) { + const state = getAssistantResponseState(assistantId); + state.rawText = mergeResponseText(state.rawText, text); + const parsedResponse = parseAssistantResponse(state.rawText); + state.taggedThinkingList = parsedResponse.thinkingList.map((content, index) => ({ + id: state.taggedThinkingList[index]?.id || nextThinkingId(), + content + })); + updateMessage(assistantId, { + content: parsedResponse.answer, + thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList], + statusText: undefined + }); + } + + function getAssistantResponseState(assistantId: number): AssistantResponseState { + const currentState = responseStateMap.get(assistantId); + if (currentState) { + return currentState; + } + const nextState: AssistantResponseState = { + rawText: "", + eventThinkingList: [], + taggedThinkingList: [] + }; + responseStateMap.set(assistantId, nextState); + return nextState; + } + + function appendAssistantThinking(assistantId: number, text: string, createSegment = false): void { + if (!text) { return; } - updateMessage(assistantId, { content: message.content + text, statusText: undefined }); + const state = getAssistantResponseState(assistantId); + const thinkingList = [...state.eventThinkingList]; + const lastThinking = thinkingList[thinkingList.length - 1]; + if (createSegment || !lastThinking) { + thinkingList.push({ id: nextThinkingId(), content: text }); + } else { + thinkingList[thinkingList.length - 1] = { + ...lastThinking, + content: lastThinking.content + text + }; + } + state.eventThinkingList = thinkingList; + updateMessage(assistantId, { + thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList], + statusText: undefined + }); } function appendCitation(assistantId: number, citation?: AiCitation): void { @@ -222,8 +293,10 @@ export function useAiChat(options: UseAiChatOptions) { } function resetAssistantMessage(id: number): void { + responseStateMap.delete(id); updateMessage(id, { content: "", + thinkingList: [], citationList: [], statusText: undefined, duration: undefined, @@ -242,6 +315,7 @@ export function useAiChat(options: UseAiChatOptions) { function clearMessages(): void { messages.value = []; + responseStateMap.clear(); } function nextMessageId(): number { @@ -249,6 +323,13 @@ export function useAiChat(options: UseAiChatOptions) { return messageSequence; } + let thinkingSequence = 0; + + function nextThinkingId(): number { + thinkingSequence += 1; + return thinkingSequence; + } + async function scrollToBottom(): Promise { await options.scrollToBottom?.(); } @@ -290,7 +371,91 @@ function extractText(payload: unknown): string { if (!isRecord(payload)) { return ""; } - return getString(payload.answer) || getString(payload.text) || getString(payload.content) || ""; + return getRawString(payload.answer) || getRawString(payload.text) || getRawString(payload.content) || ""; +} + +function mergeResponseText(currentText: string, nextText: string): string { + if (currentText && nextText.startsWith(currentText)) { + return nextText; + } + return currentText + nextText; +} + +function parseAssistantResponse(value: string): { answer: string; thinkingList: string[] } { + let answer = ""; + let thinking = ""; + let mode: "answer" | "thinking" = "answer"; + let cursor = 0; + const thinkingList: string[] = []; + + while (cursor < value.length) { + const marker = findMarker(value.slice(cursor), [...thinkOpenMarkers, ...thinkCloseMarkers]); + if (!marker) { + const rest = value.slice(cursor); + if (mode === "thinking") { + thinking += rest; + } else { + answer += rest; + } + break; + } + + const content = value.slice(cursor, cursor + marker.index); + if (mode === "thinking") { + thinking += content; + if (thinking) { + thinkingList.push(thinking); + } + thinking = ""; + mode = "answer"; + } else if (isThinkOpenMarker(marker.value)) { + answer += content; + mode = "thinking"; + } else { + if (content) { + thinkingList.push(content); + } + mode = "answer"; + } + cursor += marker.index + marker.value.length; + } + + if (mode === "thinking" && thinking) { + thinkingList.push(thinking); + } + + return { answer, thinkingList }; +} + +function findMarker(value: string, markerList: string[]): { index: number; value: string } | undefined { + const lowerValue = value.toLowerCase(); + return markerList.reduce<{ index: number; value: string } | undefined>((current, marker) => { + const index = lowerValue.indexOf(marker.toLowerCase()); + if (index < 0 || (current && current.index <= index)) { + return current; + } + return { index, value: marker }; + }, undefined); +} + +function isThinkOpenMarker(value: string): boolean { + return thinkOpenMarkers.some(marker => marker.toLowerCase() === value.toLowerCase()); +} + +function extractThinkingText(payload: unknown): string { + if (typeof payload === "string") { + return payload === "[DONE]" ? "" : payload; + } + if (!isRecord(payload)) { + return ""; + } + return getRawString(payload.thought) + || getRawString(payload.thinking) + || getRawString(payload.reasoning) + || getRawString(payload.observation) + || getRawString(payload.text) + || getRawString(payload.content) + || ""; } function formatToolStatus(payload: unknown): string { @@ -364,6 +529,10 @@ function getString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function getRawString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + function getDisplayText(value: unknown): string | undefined { const text = getString(value); if (text) { diff --git a/src/pages/ai/components/AiChatComposer.vue b/src/pages/ai/components/AiChatComposer.vue index 0f50029..2541476 100644 --- a/src/pages/ai/components/AiChatComposer.vue +++ b/src/pages/ai/components/AiChatComposer.vue @@ -4,14 +4,14 @@ - + -
- +
+
+ +
+
-
+
@@ -57,6 +67,7 @@ +