feat(ai-chat): 实现文档分片功能与SSE连接优化

- 新增文档分片相关API接口定义及请求方法
- 文档分片VO接口补充,调整项目ID等类型为字符串
- ai-chat视图中用新的带鉴权Header的SSE工具替换EventSource
- SSE连接支持事件处理及错误处理完善,提供连接关闭回调
- 知识库视图中添加文档分片查看功能,包括分片列表与详情对话框
- 界面增加分片列表操作按钮及分页数据显示
- 路由枚举调整,修正 AI Chat 相关命名混淆
- 增加SSE连接工具函数chatSSE.ts,实现带鉴权Header的SSE连接管理
This commit is contained in:
2026-03-30 18:26:06 +08:00
parent 8c09689c1c
commit 86330be8f5
6 changed files with 606 additions and 94 deletions

View File

@@ -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": []
}

View File

@@ -13,9 +13,9 @@ type Result<T = any> = {
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<Result<ChatSessionVO[]>>(
"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<Result<KbDocumentVO[]>>(
"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<Result<DocumentChunkVO[]>>(
"get",
`/api/v1/ai/kb/document/${docId}/chunks`
);
};
/** 获取分片详情 */
export const getChunkDetail = (chunkId: string) => {
return http.request<Result<DocumentChunkVO>>(
"get",
`/api/v1/ai/kb/chunk/${chunkId}`
);
};

View File

@@ -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,

140
src/utils/sse/chatSSE.ts Normal file
View File

@@ -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<string, string> = {
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 };
}
}

View File

@@ -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<ProjectItem[]>([]);
const projectLoading = ref(false);
const showProjectSelect = ref(false);
// SSE连接
let eventSource: EventSource | null = null;
// SSE连接关闭函数
let abortSSE: (() => void) | null = null;
// 消息容器引用
const messagesContainer = ref<HTMLElement | null>(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;
}
});

View File

@@ -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<KbDocumentVO[]>([]);
const projects = ref<ProjectItem[]>([]);
const selectedProjectId = ref<number | undefined>();
const selectedProjectId = ref<string | undefined>();
const projectLoading = ref(false);
// 上传相关
@@ -39,6 +43,14 @@ const uploadDialogVisible = ref(false);
const uploadFileList = ref<File[]>([]);
const fileInputRef = ref<HTMLInputElement | null>(null);
// 分片相关
const chunkDialogVisible = ref(false);
const chunkLoading = ref(false);
const currentDocChunks = ref<DocumentChunkVO[]>([]);
const currentChunkDoc = ref<KbDocumentVO | null>(null);
const selectedChunk = ref<DocumentChunkVO | null>(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"
/>
</el-select>
</div>
@@ -364,8 +402,17 @@ onMounted(() => {
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 'active'"
link
type="primary"
@click="handleViewChunks(row)"
>
<component :is="useRenderIcon(ChunkIcon)" class="mr-1" />
查看分片
</el-button>
<el-button
v-if="row.status === 'active' || row.status === 'error'"
link
@@ -455,6 +502,83 @@ onMounted(() => {
</el-button>
</template>
</el-dialog>
<!-- 分片列表对话框 -->
<el-dialog
v-model="chunkDialogVisible"
:title="`文档分片 - ${currentChunkDoc?.title || ''}`"
width="800px"
:close-on-click-modal="false"
>
<el-table
v-loading="chunkLoading"
:data="currentDocChunks"
stripe
max-height="400"
>
<el-table-column label="序号" width="80" align="center">
<template #default="{ row }">
{{ row.chunkIndex + 1 }} / {{ row.chunkTotal }}
</template>
</el-table-column>
<el-table-column label="内容预览" min-width="400">
<template #default="{ row }">
<div class="chunk-content-preview">
{{ row.content.slice(0, 200)
}}{{ row.content.length > 200 ? "..." : "" }}
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewChunkDetail(row)">
<component :is="useRenderIcon(ViewIcon)" class="mr-1" />
查看
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!chunkLoading && currentDocChunks.length === 0"
description="暂无分片数据"
/>
</el-dialog>
<!-- 分片详情对话框 -->
<el-dialog v-model="chunkDetailVisible" title="分片详情" width="700px">
<template v-if="selectedChunk">
<el-descriptions :column="2" border>
<el-descriptions-item label="分片序号">
{{ selectedChunk.chunkIndex + 1 }} / {{ selectedChunk.chunkTotal }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag size="small">{{ selectedChunk.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文档标题" :span="2">
{{ selectedChunk.title }}
</el-descriptions-item>
<el-descriptions-item label="文档类型">
{{ selectedChunk.docType }}
</el-descriptions-item>
<el-descriptions-item label="来源类型">
{{ selectedChunk.sourceType }}
</el-descriptions-item>
</el-descriptions>
<div class="chunk-detail-content">
<h4>分片内容</h4>
<div class="content-box">
{{ selectedChunk.content }}
</div>
</div>
</template>
</el-dialog>
</div>
</template>
@@ -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;
}
}
</style>