From b435bf992ed86b419d9453e0752fe2f0cf4ed51b Mon Sep 17 00:00:00 2001 From: Timi Date: Sun, 20 Sep 2026 12:03:03 +0800 Subject: [PATCH] add aiChat --- src/components/ai/AiChatComposer.vue | 163 +++++ src/components/ai/AiChatMessageList.vue | 341 +++++++++++ src/components/ai/AiChatPanel.vue | 221 +++++++ src/components/ai/AiChatThinking.vue | 94 +++ src/components/ai/index.ts | 31 + src/components/ai/types.ts | 106 ++++ src/components/ai/useAiChat.ts | 784 ++++++++++++++++++++++++ src/components/index.ts | 44 +- 8 files changed, 1782 insertions(+), 2 deletions(-) create mode 100644 src/components/ai/AiChatComposer.vue create mode 100644 src/components/ai/AiChatMessageList.vue create mode 100644 src/components/ai/AiChatPanel.vue create mode 100644 src/components/ai/AiChatThinking.vue create mode 100644 src/components/ai/index.ts create mode 100644 src/components/ai/types.ts create mode 100644 src/components/ai/useAiChat.ts diff --git a/src/components/ai/AiChatComposer.vue b/src/components/ai/AiChatComposer.vue new file mode 100644 index 0000000..ea02ab3 --- /dev/null +++ b/src/components/ai/AiChatComposer.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/src/components/ai/AiChatMessageList.vue b/src/components/ai/AiChatMessageList.vue new file mode 100644 index 0000000..af3c211 --- /dev/null +++ b/src/components/ai/AiChatMessageList.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/src/components/ai/AiChatPanel.vue b/src/components/ai/AiChatPanel.vue new file mode 100644 index 0000000..1adcc80 --- /dev/null +++ b/src/components/ai/AiChatPanel.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/src/components/ai/AiChatThinking.vue b/src/components/ai/AiChatThinking.vue new file mode 100644 index 0000000..c9a610b --- /dev/null +++ b/src/components/ai/AiChatThinking.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/components/ai/index.ts b/src/components/ai/index.ts new file mode 100644 index 0000000..7930f6d --- /dev/null +++ b/src/components/ai/index.ts @@ -0,0 +1,31 @@ +import { WebToolkit } from "timi-web"; +import AiChatComposerView from "./AiChatComposer.vue"; +import AiChatMessageListView from "./AiChatMessageList.vue"; +import AiChatPanelView from "./AiChatPanel.vue"; +import AiChatThinkingView from "./AiChatThinking.vue"; + +export const AiChatComposer = WebToolkit.withInstall(AiChatComposerView); +export const AiChatMessageList = WebToolkit.withInstall(AiChatMessageListView); +export const AiChatPanel = WebToolkit.withInstall(AiChatPanelView); +export const AiChatThinking = WebToolkit.withInstall(AiChatThinkingView); + +export default AiChatPanel; + +export { useAiChat, AiChatHttpError, AiChatStreamError, ensureChatResponse, parseSseData, readSseStream } from "./useAiChat"; +export { parseAssistantResponse } from "./useAiChat"; + +export type { + AiChatCitationFormatter, + AiChatErrorMessages, + AiChatEventAction, + AiChatMessage, + AiChatMessageStatusFormatter, + AiChatRequestContext, + AiChatRoleNameFormatter, + AiChatRoleNames, + AiChatStreamEvent, + AiCitation, + AiMessageRole, + AiThinking, + UseAiChatOptions +} from "./types"; diff --git a/src/components/ai/types.ts b/src/components/ai/types.ts new file mode 100644 index 0000000..8d9b6e2 --- /dev/null +++ b/src/components/ai/types.ts @@ -0,0 +1,106 @@ +import type { MaybeRefOrGetter } from "vue"; + +/** AI 消息角色。 */ +export type AiMessageRole = "user" | "assistant"; + +/** 消息引用信息。业务方可以通过格式化函数显示自定义字段。 */ +export interface AiCitation { + source?: string; + dataDomain?: string; + timeRange?: string; + caliber?: string; + 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; + duration?: number; +} + +/** SSE 事件。 */ +export interface AiChatStreamEvent { + event: string; + data: string; +} + +/** 发起聊天请求时传给业务适配器的上下文。 */ +export interface AiChatRequestContext { + message: string; + conversationId?: string; + context?: TContext; + signal: AbortSignal; +} + +/** SSE 事件转换后的通用动作。 */ +export type AiChatEventAction = + | { type: "thinking"; content: string; newSegment?: boolean } + | { type: "answer"; content: string } + | { type: "tool-status"; text: string } + | { type: "citation"; citation: AiCitation } + | { type: "done"; conversationId?: string; duration?: number } + | { type: "error"; error: unknown }; + +/** 可覆盖的聊天错误文案。 */ +export interface AiChatErrorMessages { + request: string; + stream: string; + unauthorized: string; + forbidden: string; + tooManyRequests: string; + timeout: string; + aborted: string; + connection: string; + unknown: string; +} + +/** 聊天请求和历史会话的业务适配器配置。 */ +export interface UseAiChatOptions { + /** 发起一个返回 SSE 响应的请求。 */ + request: (context: AiChatRequestContext) => Promise; + /** 读取历史会话,可选。 */ + readConversation?: (conversationId: string, signal: AbortSignal) => Promise; + /** 把业务历史响应转换成通用消息。 */ + mapConversationMessages?: (response: TConversationResponse) => AiChatMessage[]; + /** 用于切换业务上下文时自动清空当前会话。 */ + sessionKey?: MaybeRefOrGetter; + /** 传给 request 的业务上下文。 */ + context?: MaybeRefOrGetter; + /** 消息流式更新后的滚动回调。 */ + scrollToBottom?: () => Promise; + /** 自定义 SSE 事件转换器,未转换的事件继续使用内置协议。 */ + parseEvent?: (event: AiChatStreamEvent, payload: unknown) => AiChatEventAction | undefined; + /** 判断当前错误是否表示会话已失效。命中后会自动清空会话并重试一次。 */ + isConversationExpired?: (error: unknown) => boolean; + /** 自定义错误文案转换。 */ + getErrorMessage?: (error: unknown) => string | undefined; + /** 响应文本中的思考标记。 */ + thinkingOpenMarkers?: readonly string[]; + thinkingCloseMarkers?: readonly string[]; + /** 覆盖默认错误文案。 */ + errorMessages?: Partial; +} + +/** 消息列表的角色文案。 */ +export type AiChatRoleNames = Partial>; + +/** 消息状态格式化函数。 */ +export type AiChatMessageStatusFormatter = (message: AiChatMessage) => string; + +/** 引用格式化函数。 */ +export type AiChatCitationFormatter = (citation: AiCitation) => string; + +/** 角色名称格式化函数。 */ +export type AiChatRoleNameFormatter = (role: AiMessageRole, message: AiChatMessage) => string; diff --git a/src/components/ai/useAiChat.ts b/src/components/ai/useAiChat.ts new file mode 100644 index 0000000..055be2e --- /dev/null +++ b/src/components/ai/useAiChat.ts @@ -0,0 +1,784 @@ +import { computed, onUnmounted, shallowRef, toValue, watch } from "vue"; +import type { + AiChatErrorMessages, + AiChatEventAction, + AiChatMessage, + AiChatRequestContext, + AiChatStreamEvent, + AiCitation, + AiThinking, + UseAiChatOptions +} from "./types"; + +export type { + AiChatMessage, + AiChatErrorMessages, + AiChatEventAction, + AiChatRequestContext, + AiChatStreamEvent, + AiCitation, + AiMessageRole, + AiThinking, + UseAiChatOptions +} from "./types"; + +export { parseAssistantResponse }; + +/** 通用聊天 HTTP 错误。 */ +export class AiChatHttpError extends Error { + readonly status: number; + readonly code?: string; + + constructor(status: number, message: string, code?: string) { + super(message); + this.name = "AiChatHttpError"; + this.status = status; + this.code = code; + } +} + +/** 通用聊天流错误。 */ +export class AiChatStreamError extends Error { + readonly code?: string; + + constructor(message: string, code?: string) { + super(message); + this.name = "AiChatStreamError"; + this.code = code; + } +} + +const DEFAULT_ERROR_MESSAGES: AiChatErrorMessages = { + request: "聊天请求失败,请稍后重试", + stream: "聊天连接中断,请重试", + unauthorized: "登录状态已失效,请重新登录", + forbidden: "没有权限使用聊天服务", + tooManyRequests: "请求过于频繁,请稍后再试", + timeout: "聊天请求超时,请稍后重试", + aborted: "已停止生成,可重新提问", + connection: "连接中断,可重新提问", + unknown: "聊天服务暂时不可用,请稍后重试" +}; + +const DEFAULT_THINKING_OPEN_MARKERS = ["", ""]; +const DEFAULT_THINKING_CLOSE_MARKERS = ["", ""]; + +interface AssistantResponseState { + rawText: string; + eventThinkingList: AiThinking[]; + taggedThinkingList: AiThinking[]; +} + +/** + * 管理通用 AI 聊天的会话、SSE 流式消息、取消和重试。 + * + * 业务 API 通过 request 和可选的历史会话回调注入,composable 本身不依赖具体 + * 登录 Store、HTTP 客户端或业务数据模型。 + */ +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(() => Boolean( + lastError.value + && lastQuestion.value + && !isGenerating.value + && !isLoadingConversation.value + )); + let activeController: AbortController | undefined; + let messageSequence = 0; + let thinkingSequence = 0; + const responseStateMap = new Map(); + const errorMessages = { ...DEFAULT_ERROR_MESSAGES, ...options.errorMessages }; + + async function sendMessage(message: string): Promise { + const normalizedMessage = message.trim(); + if (!normalizedMessage || isGenerating.value || isLoadingConversation.value) { + return; + } + + const userMessage = createMessage("user", normalizedMessage); + const assistantMessage = createMessage("assistant", ""); + 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 + && options.isConversationExpired?.(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 = ""; + responseStateMap.delete(assistantMessage.id); + 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: errorMessages.aborted }); + } + } + + function resetConversation(): void { + activeController?.abort(); + activeController = undefined; + messages.value = []; + responseStateMap.clear(); + conversationId.value = ""; + isGenerating.value = false; + isLoadingConversation.value = false; + activeToolStatus.value = ""; + lastError.value = ""; + lastQuestion.value = ""; + } + + async function loadConversation(id: string): Promise { + const normalizedId = id.trim(); + if ( + !normalizedId + || isGenerating.value + || isLoadingConversation.value + || !options.readConversation + ) { + return; + } + + isLoadingConversation.value = true; + messages.value = []; + responseStateMap.clear(); + conversationId.value = normalizedId; + lastError.value = ""; + lastQuestion.value = ""; + const controller = new AbortController(); + try { + const response = await options.readConversation(normalizedId, controller.signal); + if (conversationId.value !== normalizedId) { + return; + } + messages.value = options.mapConversationMessages + ? options.mapConversationMessages(response) + : normalizeConversationMessages(response); + 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 retryLastMessage(): Promise { + if (canRetry.value) { + await sendMessage(lastQuestion.value); + } + } + + async function readChatStream( + message: string, + assistantId: number, + controller: AbortController + ): Promise { + const requestContext: AiChatRequestContext = { + message, + conversationId: conversationId.value || undefined, + context: options.context ? toValue(options.context) : undefined, + signal: controller.signal + }; + const response = await options.request(requestContext); + await ensureChatResponse(response, errorMessages.request); + let hasDoneEvent = false; + await readSseStream(response, async event => { + hasDoneEvent = handleStreamEvent(event, assistantId, hasDoneEvent); + await options.scrollToBottom?.(); + }); + if (!hasDoneEvent && !controller.signal.aborted) { + throw new AiChatStreamError(errorMessages.stream, "STREAM_INCOMPLETE"); + } + } + + function handleStreamEvent(event: AiChatStreamEvent, assistantId: number, hasDoneEvent: boolean): boolean { + const payload = parseSseData(event.data); + const action = options.parseEvent?.(event, payload) || parseDefaultEvent(event, payload); + if (!action) { + return hasDoneEvent; + } + switch (action.type) { + case "thinking": + appendAssistantThinking(assistantId, action.content, action.newSegment === true); + return hasDoneEvent; + case "answer": + appendAssistantResponse(assistantId, action.content); + return hasDoneEvent; + case "tool-status": + activeToolStatus.value = action.text; + return hasDoneEvent; + case "citation": + appendCitation(assistantId, action.citation); + return hasDoneEvent; + case "done": + applyDoneAction(assistantId, action); + return true; + case "error": + throw action.error; + } + } + + function applyDoneAction(assistantId: number, action: Extract): void { + if (action.conversationId) { + conversationId.value = action.conversationId; + } + updateMessage(assistantId, { + createdAt: Date.now(), + duration: action.duration + }); + } + + function appendAssistantResponse(assistantId: number, text: string): void { + if (!text) { + return; + } + const state = getAssistantResponseState(assistantId); + state.rawText = mergeResponseText(state.rawText, text); + const parsedResponse = parseAssistantResponse( + state.rawText, + options.thinkingOpenMarkers || DEFAULT_THINKING_OPEN_MARKERS, + options.thinkingCloseMarkers || DEFAULT_THINKING_CLOSE_MARKERS + ); + 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 appendAssistantThinking(assistantId: number, text: string, createSegment = false): void { + if (!text) { + return; + } + 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 { + const message = messages.value.find(item => item.id === assistantId); + if (!message) { + return; + } + updateMessage(assistantId, { citationList: [...message.citationList, citation] }); + } + + function handleChatError(error: unknown, assistantId: number, isAborted: boolean): void { + if (isAborted) { + return; + } + const message = options.getErrorMessage?.(error) || getDefaultErrorMessage(error, errorMessages); + lastError.value = message; + updateMessage(assistantId, { statusText: message }); + } + + 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 updateMessage(id: number, patch: Partial): void { + messages.value = messages.value.map(item => item.id === id ? { ...item, ...patch } : item); + } + + function resetAssistantMessage(id: number): void { + responseStateMap.delete(id); + updateMessage(id, { + content: "", + thinkingList: [], + citationList: [], + statusText: undefined, + duration: undefined, + createdAt: Date.now() + }); + } + + function createMessage(role: AiChatMessage["role"], content: string): AiChatMessage { + return { + id: nextMessageId(), + role, + content, + thinkingList: [], + citationList: [], + createdAt: Date.now() + }; + } + + function nextMessageId(): number { + messageSequence += 1; + return messageSequence; + } + + function nextThinkingId(): number { + thinkingSequence += 1; + return thinkingSequence; + } + + async function scrollToBottom(): Promise { + await options.scrollToBottom?.(); + } + + if (options.sessionKey !== undefined) { + watch(() => toValue(options.sessionKey), () => { + resetConversation(); + }, { flush: "sync" }); + } + + onUnmounted(() => { + activeController?.abort(); + messages.value = []; + responseStateMap.clear(); + }); + + return { + messages, + conversationId: computed(() => conversationId.value), + isGenerating, + isLoadingConversation, + activeToolStatus, + lastError, + canRetry, + sendMessage, + loadConversation, + stopGeneration, + resetConversation, + retryLastMessage + }; +} + +/** 读取 SSE 响应并按事件块回调。 */ +export async function readSseStream( + response: Response, + onEvent: (event: AiChatStreamEvent) => Promise +): Promise { + if (!response.body) { + throw new AiChatStreamError("聊天响应没有可读取的数据流", "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(); + } +} + +/** 将非 2xx 响应转换成通用错误,不暴露原始响应正文。 */ +export async function ensureChatResponse(response: Response, fallbackMessage = DEFAULT_ERROR_MESSAGES.request): Promise { + if (response.ok) { + return; + } + + let code: string | undefined; + let message = fallbackMessage; + try { + const payload = await response.json() as { + code?: unknown; + message?: unknown; + error?: unknown; + }; + code = getString(payload.code); + message = getString(payload.message) || getString(payload.error) || message; + } catch { + // 非 JSON 响应使用安全默认文案。 + } + throw new AiChatHttpError(response.status, message, code); +} + +/** 解析 SSE data,解析失败时返回原始文本。 */ +export function parseSseData(data: string): unknown { + const normalizedData = data.trim(); + if (normalizedData === "[DONE]") { + return normalizedData; + } + try { + return JSON.parse(normalizedData) as unknown; + } catch { + return data; + } +} + +/** 解析包含思考标记的完整响应文本。 */ +function parseAssistantResponse( + value: string, + openMarkers: readonly string[] = DEFAULT_THINKING_OPEN_MARKERS, + closeMarkers: readonly string[] = DEFAULT_THINKING_CLOSE_MARKERS +): { 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), [...openMarkers, ...closeMarkers]); + 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 (openMarkers.some(item => item.toLowerCase() === marker.value.toLowerCase())) { + 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 }; +} + +async function consumeSseBuffer( + buffer: string, + onEvent: (event: AiChatStreamEvent) => 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): AiChatStreamEvent | 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") }; +} + +function parseDefaultEvent(event: AiChatStreamEvent, payload: unknown): AiChatEventAction | undefined { + switch (event.event.toLowerCase()) { + case "think": + case "thinking": + return { type: "thinking", content: extractThinkingText(payload) }; + case "agent_thought": + return { type: "thinking", content: extractThinkingText(payload), newSegment: true }; + case "message": + case "answer": + case "text": + return { type: "answer", content: extractText(payload) }; + case "tool_status": + case "tool-status": + return { type: "tool-status", text: extractToolStatus(payload) }; + case "citation": + case "source": { + const citation = normalizeCitation(payload); + return citation ? { type: "citation", citation } : undefined; + } + case "done": + case "finish": + case "completed": + return { type: "done", ...normalizeDonePayload(payload) }; + case "error": + return { type: "error", error: createStreamError(payload) }; + default: + return undefined; + } +} + +function normalizeDonePayload(payload: unknown): { conversationId?: string; duration?: number } { + if (!isRecord(payload)) { + return {}; + } + return { + conversationId: getString(payload.conversationId) || getString(payload.conversation_id), + duration: getNumber(payload.duration) + }; +} + +function extractText(payload: unknown): string { + if (typeof payload === "string") { + return payload === "[DONE]" ? "" : payload; + } + if (!isRecord(payload)) { + return ""; + } + return getRawString(payload.answer) + || getRawString(payload.text) + || getRawString(payload.content) + || ""; +} + +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 extractToolStatus(payload: unknown): string { + if (typeof payload === "string") { + return payload === "[DONE]" ? "" : payload; + } + if (!isRecord(payload)) { + return ""; + } + return getString(payload.text) + || getString(payload.message) + || getString(payload.statusText) + || getString(payload.status) + || ""; +} + +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): AiChatStreamError { + if (!isRecord(payload)) { + return new AiChatStreamError(DEFAULT_ERROR_MESSAGES.unknown, "STREAM_ERROR"); + } + return new AiChatStreamError( + getString(payload.message) || DEFAULT_ERROR_MESSAGES.unknown, + getString(payload.code) + ); +} + +function getDefaultErrorMessage(error: unknown, messages: AiChatErrorMessages): string { + if (error instanceof AiChatHttpError) { + if (error.status === 401) { + return messages.unauthorized; + } + if (error.status === 403) { + return messages.forbidden; + } + if (error.status === 429) { + return messages.tooManyRequests; + } + return error.message; + } + if (error instanceof AiChatStreamError) { + if (error.code?.toLowerCase().includes("timeout")) { + return messages.timeout; + } + return error.message; + } + if (error instanceof DOMException && error.name === "AbortError") { + return messages.aborted; + } + if (error instanceof TypeError) { + return messages.connection; + } + return messages.unknown; +} + +function normalizeConversationMessages(response: unknown): AiChatMessage[] { + if (Array.isArray(response)) { + return response as AiChatMessage[]; + } + if (isRecord(response) && Array.isArray(response.data)) { + return response.data as AiChatMessage[]; + } + return []; +} + +function mergeResponseText(currentText: string, nextText: string): string { + if (currentText && nextText.startsWith(currentText)) { + return nextText; + } + return currentText + nextText; +} + +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 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 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 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 getRawString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function getNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} diff --git a/src/components/index.ts b/src/components/index.ts index db4d73b..9d34599 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,6 +10,12 @@ import NotifyEditor from "./notify-editor"; import NotifyView from "./notify-view"; import BottomPopup from "./bottom-popup"; import Space from "./space"; +import { + AiChatComposer, + AiChatMessageList, + AiChatPanel, + AiChatThinking +} from "./ai"; export default [ RootLayout, @@ -22,7 +28,11 @@ export default [ NotifyEditor, NotifyView, BottomPopup, - Space + Space, + AiChatComposer, + AiChatMessageList, + AiChatPanel, + AiChatThinking ]; export { @@ -36,9 +46,39 @@ export { NotifyEditor, NotifyView, BottomPopup, - Space + Space, + AiChatComposer, + AiChatMessageList, + AiChatPanel, + AiChatThinking }; +export { + AiChatHttpError, + AiChatStreamError, + ensureChatResponse, + parseAssistantResponse, + parseSseData, + readSseStream, + useAiChat +} from "./ai"; + +export type { + AiChatCitationFormatter, + AiChatErrorMessages, + AiChatEventAction, + AiChatMessage, + AiChatMessageStatusFormatter, + AiChatRequestContext, + AiChatRoleNameFormatter, + AiChatRoleNames, + AiChatStreamEvent, + AiCitation, + AiMessageRole, + AiThinking, + UseAiChatOptions +} from "./ai"; + export type { SpaceAlign, SpaceDirection,