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:
184
src/api/AI知识库.openapi.json
Normal file
184
src/api/AI知识库.openapi.json
Normal 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": []
|
||||
}
|
||||
@@ -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}`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
140
src/utils/sse/chatSSE.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user