From 86330be8f587941c15af58a2b43d91684fb5971b Mon Sep 17 00:00:00 2001 From: JiaoTianBo Date: Mon, 30 Mar 2026 18:26:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-chat):=20=E5=AE=9E=E7=8E=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=88=86=E7=89=87=E5=8A=9F=E8=83=BD=E4=B8=8ESSE?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增文档分片相关API接口定义及请求方法 - 文档分片VO接口补充,调整项目ID等类型为字符串 - ai-chat视图中用新的带鉴权Header的SSE工具替换EventSource - SSE连接支持事件处理及错误处理完善,提供连接关闭回调 - 知识库视图中添加文档分片查看功能,包括分片列表与详情对话框 - 界面增加分片列表操作按钮及分页数据显示 - 路由枚举调整,修正 AI Chat 相关命名混淆 - 增加SSE连接工具函数chatSSE.ts,实现带鉴权Header的SSE连接管理 --- src/api/AI知识库.openapi.json | 184 +++++++++++++++++++++++++++++ src/api/ai-chat.ts | 47 ++++++-- src/router/enums.ts | 4 +- src/utils/sse/chatSSE.ts | 140 ++++++++++++++++++++++ src/views/ai-chat/index.vue | 162 +++++++++++++------------ src/views/knowledge-base/index.vue | 163 ++++++++++++++++++++++++- 6 files changed, 606 insertions(+), 94 deletions(-) create mode 100644 src/api/AI知识库.openapi.json create mode 100644 src/utils/sse/chatSSE.ts diff --git a/src/api/AI知识库.openapi.json b/src/api/AI知识库.openapi.json new file mode 100644 index 0000000..45e14b2 --- /dev/null +++ b/src/api/AI知识库.openapi.json @@ -0,0 +1,184 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "默认模块", + "description": "", + "version": "1.0.0" + }, + "tags": [], + "paths": { + "/api/v1/ai/kb/document/{docId}/chunks": { + "get": { + "summary": "获取文档分片列表", + "deprecated": false, + "description": "获取文档分片列表\n获取文档分片列表\n获取指定文档的所有分片信息", + "tags": [], + "parameters": [ + { + "name": "docId", + "in": "path", + "description": "文档UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "", + "example": "Bearer b35c6f5b-bc0b-4652-bef2-eca04a5cdd95", + "schema": { + "type": "string", + "default": "Bearer b35c6f5b-bc0b-4652-bef2-eca04a5cdd95" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseResponseListDocumentChunkVO", + "description": "分片列表" + } + } + } + } + }, + "security": [] + } + }, + "/api/v1/ai/kb/chunk/{chunkId}": { + "get": { + "summary": "获取分片详情", + "deprecated": false, + "description": "获取分片详情\n获取分片详情\n获取指定分片的详细信息", + "tags": [], + "parameters": [ + { + "name": "chunkId", + "in": "path", + "description": "分片ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Authorization", + "in": "header", + "description": "", + "example": "Bearer b35c6f5b-bc0b-4652-bef2-eca04a5cdd95", + "schema": { + "type": "string", + "default": "Bearer b35c6f5b-bc0b-4652-bef2-eca04a5cdd95" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BaseResponseDocumentChunkVO", + "description": "分片详情" + } + } + } + } + }, + "security": [] + } + } + }, + "components": { + "schemas": { + "DocumentChunkVO": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "分片ID" + }, + "docId": { + "type": "string", + "description": "原始文档ID" + }, + "content": { + "type": "string", + "description": "分片内容" + }, + "chunkIndex": { + "type": "integer", + "description": "分片序号" + }, + "chunkTotal": { + "type": "integer", + "description": "总分片数" + }, + "title": { + "type": "string", + "description": "文档标题" + }, + "docType": { + "type": "string", + "description": "文档类型" + }, + "sourceType": { + "type": "string", + "description": "来源类型" + }, + "status": { + "type": "string", + "description": "状态" + } + } + }, + "BaseResponseListDocumentChunkVO": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "description": "" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentChunkVO", + "description": "文档分片VO" + }, + "description": "" + }, + "message": { + "type": "string", + "description": "" + } + } + }, + "BaseResponseDocumentChunkVO": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "description": "" + }, + "data": { + "$ref": "#/components/schemas/DocumentChunkVO", + "description": "" + }, + "message": { + "type": "string", + "description": "" + } + } + } + }, + "responses": {}, + "securitySchemes": {} + }, + "servers": [], + "security": [] +} diff --git a/src/api/ai-chat.ts b/src/api/ai-chat.ts index 411005a..c6ebd55 100644 --- a/src/api/ai-chat.ts +++ b/src/api/ai-chat.ts @@ -13,9 +13,9 @@ type Result = { export interface ChatSessionVO { sessionId: string; // UUID格式 sessionTitle: string; - projectId: number; + projectId: string; projectName: string; - timelineNodeId?: number; + timelineNodeId?: string; timelineNodeName?: string; lastMessageTime: string; // ISO 8601格式 messageCount: number; @@ -63,6 +63,19 @@ export interface KbDocumentVO { createTime: string; } +/** 文档分片VO */ +export interface DocumentChunkVO { + id: string; // 分片ID + docId: string; // 原始文档ID + content: string; // 分片内容 + chunkIndex: number; // 分片序号 + chunkTotal: number; // 总分片数 + title: string; // 文档标题 + docType: string; // 文档类型 + sourceType: string; // 来源类型 + status: string; // 状态 +} + /** 文档状态 */ export type DocumentStatus = "pending" | "processing" | "active" | "error"; @@ -99,8 +112,8 @@ export interface SSEErrorData { /** 创建会话请求参数 */ export interface CreateSessionRequest { - projectId: number; - timelineNodeId?: number | null; + projectId: string; + timelineNodeId?: string | null; firstMessage?: string; sessionTitle?: string; } @@ -108,8 +121,8 @@ export interface CreateSessionRequest { /** SSE对话请求参数 */ export interface SSEChatParams { sessionId?: string; // UUID格式,为空则新建会话 - projectId: number; - timelineNodeId?: number; + projectId: string; + timelineNodeId?: string; message: string; useRag?: boolean; // 默认true useTextToSql?: boolean; // 默认false @@ -118,7 +131,7 @@ export interface SSEChatParams { /** 上传文件请求参数 */ export interface UploadDocumentParams { - projectId: number; + projectId: string; file: File; } @@ -136,7 +149,7 @@ export const createChatSession = (data: CreateSessionRequest) => { }; /** 获取会话列表 */ -export const getChatSessions = (projectId: number) => { +export const getChatSessions = (projectId: string) => { return http.request>( "get", "/api/v1/ai/chat/sessions", @@ -204,7 +217,7 @@ export const uploadDocument = (params: UploadDocumentParams) => { }; /** 获取文档列表 */ -export const getDocuments = (projectId: number) => { +export const getDocuments = (projectId: string) => { return http.request>( "get", "/api/v1/ai/kb/documents", @@ -229,3 +242,19 @@ export const reindexDocument = (docId: string) => { `/api/v1/ai/kb/document/${docId}/reindex` ); }; + +/** 获取文档分片列表 */ +export const getDocumentChunks = (docId: string) => { + return http.request>( + "get", + `/api/v1/ai/kb/document/${docId}/chunks` + ); +}; + +/** 获取分片详情 */ +export const getChunkDetail = (chunkId: string) => { + return http.request>( + "get", + `/api/v1/ai/kb/chunk/${chunkId}` + ); +}; diff --git a/src/router/enums.ts b/src/router/enums.ts index fc1716b..b2f248e 100644 --- a/src/router/enums.ts +++ b/src/router/enums.ts @@ -1,9 +1,9 @@ // 完整版菜单比较多,将 rank 抽离出来,在此方便维护 const home = 0, // 平台规定只有 home 路由的 rank 才能为 0 ,所以后端在返回 rank 的时候需要从非 0 开始 - chatai = 1, + aiChat = 1, project = 2, - aiChat = 3, + chatai = 3, knowledgeBase = 4, vueflow = 5, ganttastic = 6, diff --git a/src/utils/sse/chatSSE.ts b/src/utils/sse/chatSSE.ts new file mode 100644 index 0000000..ca38551 --- /dev/null +++ b/src/utils/sse/chatSSE.ts @@ -0,0 +1,140 @@ +/** + * SSE 工具函数 + * 用于 AI Chat 等场景的 SSE 连接,支持鉴权 Header + */ + +import { getToken, formatToken } from "@/utils/auth"; + +export interface SSEOptions { + /** SSE 连接 URL */ + url: string; + /** 事件回调 */ + onEvent: (eventName: string, data: any) => void; + /** 错误回调 */ + onError?: (error: Error) => void; + /** 连接完成回调 */ + onComplete?: () => void; +} + +/** + * 创建带鉴权 Header 的 SSE 连接 + * 返回一个 abort 函数用于手动关闭连接 + */ +export async function createSSEConnection( + options: SSEOptions +): Promise<() => void> { + const { url, onEvent, onError, onComplete } = options; + const abortController = new AbortController(); + + // 获取鉴权 Header + const headers: Record = { + Accept: "text/event-stream", + "Cache-Control": "no-cache" + }; + + const tokenData = getToken(); + if (tokenData?.accessToken) { + headers["Authorization"] = formatToken(tokenData.accessToken); + } + + try { + const response = await fetch(url, { + method: "GET", + headers, + signal: abortController.signal + }); + + if (!response.ok) { + throw new Error( + `SSE 连接失败: ${response.status} ${response.statusText}` + ); + } + + if (!response.body) { + throw new Error("SSE 响应无 body"); + } + + // 读取 SSE 流 + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + // 异步读取流 + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + console.log("SSE 流结束"); + onComplete?.(); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // 解析 SSE 消息(以双换行分隔) + const messages = buffer.split("\n\n"); + buffer = messages.pop() || ""; // 保留未完成的消息 + + for (const msg of messages) { + if (msg.trim()) { + const { eventName, data } = parseSSEMessage(msg); + if (eventName && data) { + onEvent(eventName, data); + } + } + } + } + } catch (error: any) { + if (error.name === "AbortError") { + console.log("SSE 连接已取消"); + return; + } + console.error("SSE 读取错误:", error); + onError?.(error); + } + })(); + } catch (error: any) { + if (error.name === "AbortError") { + console.log("SSE 连接已取消"); + return () => {}; + } + console.error("SSE 连接错误:", error); + onError?.(error); + } + + // 返回 abort 函数 + return () => { + abortController.abort(); + }; +} + +/** + * 解析 SSE 消息 + */ +function parseSSEMessage(rawMessage: string): { eventName: string; data: any } { + const lines = rawMessage.split("\n"); + let eventName = "message"; + let dataStr = ""; + + for (const line of lines) { + if (line.startsWith("event:")) { + eventName = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + dataStr = line.slice(5).trim(); + } + } + + if (!dataStr) { + return { eventName, data: null }; + } + + try { + const data = JSON.parse(dataStr); + return { eventName, data }; + } catch (e) { + console.error("SSE 消息解析失败:", dataStr, e); + return { eventName, data: null }; + } +} diff --git a/src/views/ai-chat/index.vue b/src/views/ai-chat/index.vue index 42d4d96..cc70772 100644 --- a/src/views/ai-chat/index.vue +++ b/src/views/ai-chat/index.vue @@ -18,6 +18,7 @@ import { type SSEErrorData } from "@/api/ai-chat"; import { getProjectList, type ProjectItem } from "@/api/project"; +import { createSSEConnection } from "@/utils/sse/chatSSE"; import dayjs from "dayjs"; import SendIcon from "~icons/ri/send-plane-fill"; @@ -41,8 +42,8 @@ const projects = ref([]); const projectLoading = ref(false); const showProjectSelect = ref(false); -// SSE连接 -let eventSource: EventSource | null = null; +// SSE连接关闭函数 +let abortSSE: (() => void) | null = null; // 消息容器引用 const messagesContainer = ref(null); @@ -67,11 +68,11 @@ async function loadProjects() { } // 加载会话列表 -async function loadSessions(projectId?: number) { +async function loadSessions(projectId?: string) { loading.value = true; try { const res = await getChatSessions( - projectId || currentSession.value?.projectId || 0 + projectId || currentSession.value?.projectId || "" ); if (res.code === 200) { sessions.value = res.data || []; @@ -116,7 +117,7 @@ async function handleProjectSelect(project: ProjectItem) { currentSession.value = { sessionId: "", sessionTitle: "新对话", - projectId: Number(project.id), + projectId: String(project.id), projectName: project.projectName || "", lastMessageTime: new Date().toISOString(), messageCount: 0, @@ -163,81 +164,86 @@ async function sendMessage() { }); // 关闭之前的连接 - if (eventSource) { - eventSource.close(); - eventSource = null; + if (abortSSE) { + abortSSE(); + abortSSE = null; } - eventSource = new EventSource(sseUrl); + // 使用带鉴权Header的SSE连接 + abortSSE = await createSSEConnection({ + url: sseUrl, + onEvent: (eventName: string, data: any) => { + switch (eventName) { + case "start": { + const startData = data as SSEStartData; + if (startData.isNewSession || !currentSession.value?.sessionId) { + currentSession.value = { + ...currentSession.value!, + sessionId: startData.sessionId, + sessionTitle: message.slice(0, 20) + }; + loadSessions(currentSession.value.projectId); + } + break; + } + case "chunk": { + const chunkData = data as SSEChunkData; + streamingMessage.value += chunkData.content; + scrollToBottom(); + break; + } + case "references": { + const refData = data as SSEReferencesData; + streamingReferences.value = refData.docs || []; + break; + } + case "complete": { + const completeData = data as SSECompleteData; - eventSource.addEventListener("start", (e: MessageEvent) => { - const data: SSEStartData = JSON.parse(e.data); - if (data.isNewSession || !currentSession.value.sessionId) { - currentSession.value = { - ...currentSession.value, - sessionId: data.sessionId, - sessionTitle: message.slice(0, 20) - }; - loadSessions(currentSession.value.projectId); + // 添加AI回复消息 + const assistantMessage: ChatMessageVO = { + id: completeData.messageId, + role: "assistant", + content: streamingMessage.value, + referencedDocs: streamingReferences.value, + tokensUsed: completeData.tokensUsed, + messageIndex: messages.value.length + 1, + createTime: new Date().toISOString() + }; + messages.value.push(assistantMessage); + + // 重置状态 + streamingMessage.value = ""; + streamingReferences.value = []; + sending.value = false; + abortSSE = null; + + loadSessions(currentSession.value?.projectId); + scrollToBottom(); + break; + } + case "error": { + const errorData = data as SSEErrorData; + ElMessage.error(errorData.message || "对话发生错误"); + sending.value = false; + streamingMessage.value = ""; + streamingReferences.value = []; + if (abortSSE) { + abortSSE(); + abortSSE = null; + } + break; + } + } + }, + onError: (error: Error) => { + ElMessage.error("连接服务器失败: " + error.message); + sending.value = false; + streamingMessage.value = ""; + streamingReferences.value = []; + abortSSE = null; } }); - - eventSource.addEventListener("chunk", (e: MessageEvent) => { - const data: SSEChunkData = JSON.parse(e.data); - streamingMessage.value += data.content; - scrollToBottom(); - }); - - eventSource.addEventListener("references", (e: MessageEvent) => { - const data: SSEReferencesData = JSON.parse(e.data); - streamingReferences.value = data.docs || []; - }); - - eventSource.addEventListener("complete", (e: MessageEvent) => { - const data: SSECompleteData = JSON.parse(e.data); - - // 添加AI回复消息 - const assistantMessage: ChatMessageVO = { - id: data.messageId, - role: "assistant", - content: streamingMessage.value, - referencedDocs: streamingReferences.value, - tokensUsed: data.tokensUsed, - messageIndex: messages.value.length + 1, - createTime: new Date().toISOString() - }; - messages.value.push(assistantMessage); - - // 重置状态 - streamingMessage.value = ""; - streamingReferences.value = []; - sending.value = false; - - eventSource?.close(); - eventSource = null; - - loadSessions(currentSession.value?.projectId); - scrollToBottom(); - }); - - eventSource.addEventListener("error", (e: MessageEvent) => { - const data: SSEErrorData = JSON.parse(e.data); - ElMessage.error(data.message || "对话发生错误"); - sending.value = false; - streamingMessage.value = ""; - streamingReferences.value = []; - eventSource?.close(); - eventSource = null; - }); - - eventSource.onerror = () => { - ElMessage.error("连接服务器失败"); - sending.value = false; - streamingMessage.value = ""; - streamingReferences.value = []; - eventSource?.close(); - eventSource = null; - }; } // 删除会话 @@ -289,9 +295,9 @@ function formatFileSize(bytes: number) { // 组件卸载时关闭SSE连接 onUnmounted(() => { - if (eventSource) { - eventSource.close(); - eventSource = null; + if (abortSSE) { + abortSSE(); + abortSSE = null; } }); diff --git a/src/views/knowledge-base/index.vue b/src/views/knowledge-base/index.vue index b048bc6..dd2047b 100644 --- a/src/views/knowledge-base/index.vue +++ b/src/views/knowledge-base/index.vue @@ -7,8 +7,10 @@ import { uploadDocument, deleteDocument, reindexDocument, + getDocumentChunks, type KbDocumentVO, - type DocumentStatus + type DocumentStatus, + type DocumentChunkVO } from "@/api/ai-chat"; import { getProjectList, type ProjectItem } from "@/api/project"; import dayjs from "dayjs"; @@ -21,6 +23,8 @@ import FileIcon from "~icons/ri/file-text-line"; import PdfIcon from "~icons/ri/file-pdf-line"; import DocIcon from "~icons/ri/file-word-line"; import TxtIcon from "~icons/ri/file-text-line"; +import ViewIcon from "~icons/ri/eye-line"; +import ChunkIcon from "~icons/ri/file-list-3-line"; defineOptions({ name: "KnowledgeBase" @@ -31,7 +35,7 @@ const loading = ref(false); const uploading = ref(false); const documents = ref([]); const projects = ref([]); -const selectedProjectId = ref(); +const selectedProjectId = ref(); const projectLoading = ref(false); // 上传相关 @@ -39,6 +43,14 @@ const uploadDialogVisible = ref(false); const uploadFileList = ref([]); const fileInputRef = ref(null); +// 分片相关 +const chunkDialogVisible = ref(false); +const chunkLoading = ref(false); +const currentDocChunks = ref([]); +const currentChunkDoc = ref(null); +const selectedChunk = ref(null); +const chunkDetailVisible = ref(false); + // 加载项目列表 async function loadProjects() { projectLoading.value = true; @@ -47,7 +59,7 @@ async function loadProjects() { if (res.code === 200) { projects.value = res.data?.rows || []; if (projects.value.length > 0 && !selectedProjectId.value) { - selectedProjectId.value = Number(projects.value[0].id); + selectedProjectId.value = String(projects.value[0].id); } } } catch (error) { @@ -196,6 +208,32 @@ async function handleReindex(doc: KbDocumentVO) { } } +// 查看文档分片 +async function handleViewChunks(doc: KbDocumentVO) { + currentChunkDoc.value = doc; + chunkLoading.value = true; + chunkDialogVisible.value = true; + selectedChunk.value = null; + + try { + const res = await getDocumentChunks(doc.docId); + if (res.code === 200) { + currentDocChunks.value = res.data || []; + } + } catch (error) { + console.error("加载分片失败:", error); + ElMessage.error("加载分片失败"); + } finally { + chunkLoading.value = false; + } +} + +// 查看分片详情 +function handleViewChunkDetail(chunk: DocumentChunkVO) { + selectedChunk.value = chunk; + chunkDetailVisible.value = true; +} + // 获取状态标签类型 function getStatusType( status: DocumentStatus @@ -293,7 +331,7 @@ onMounted(() => { v-for="project in projects" :key="project.id" :label="project.projectName" - :value="Number(project.id)" + :value="project.id" /> @@ -364,8 +402,17 @@ onMounted(() => { - + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -505,4 +629,33 @@ onMounted(() => { } } } + +.chunk-content-preview { + font-size: 13px; + line-height: 1.5; + color: var(--el-text-color-regular); + word-break: break-all; + white-space: pre-wrap; +} + +.chunk-detail-content { + margin-top: 16px; + + h4 { + margin-bottom: 8px; + font-weight: 500; + } + + .content-box { + max-height: 300px; + padding: 12px; + overflow-y: auto; + font-size: 13px; + line-height: 1.6; + overflow-wrap: break-word; + white-space: pre-wrap; + background: var(--el-fill-color-lighter); + border-radius: 4px; + } +}