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,