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 {
|
export interface ChatSessionVO {
|
||||||
sessionId: string; // UUID格式
|
sessionId: string; // UUID格式
|
||||||
sessionTitle: string;
|
sessionTitle: string;
|
||||||
projectId: number;
|
projectId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
timelineNodeId?: number;
|
timelineNodeId?: string;
|
||||||
timelineNodeName?: string;
|
timelineNodeName?: string;
|
||||||
lastMessageTime: string; // ISO 8601格式
|
lastMessageTime: string; // ISO 8601格式
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
@@ -63,6 +63,19 @@ export interface KbDocumentVO {
|
|||||||
createTime: string;
|
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";
|
export type DocumentStatus = "pending" | "processing" | "active" | "error";
|
||||||
|
|
||||||
@@ -99,8 +112,8 @@ export interface SSEErrorData {
|
|||||||
|
|
||||||
/** 创建会话请求参数 */
|
/** 创建会话请求参数 */
|
||||||
export interface CreateSessionRequest {
|
export interface CreateSessionRequest {
|
||||||
projectId: number;
|
projectId: string;
|
||||||
timelineNodeId?: number | null;
|
timelineNodeId?: string | null;
|
||||||
firstMessage?: string;
|
firstMessage?: string;
|
||||||
sessionTitle?: string;
|
sessionTitle?: string;
|
||||||
}
|
}
|
||||||
@@ -108,8 +121,8 @@ export interface CreateSessionRequest {
|
|||||||
/** SSE对话请求参数 */
|
/** SSE对话请求参数 */
|
||||||
export interface SSEChatParams {
|
export interface SSEChatParams {
|
||||||
sessionId?: string; // UUID格式,为空则新建会话
|
sessionId?: string; // UUID格式,为空则新建会话
|
||||||
projectId: number;
|
projectId: string;
|
||||||
timelineNodeId?: number;
|
timelineNodeId?: string;
|
||||||
message: string;
|
message: string;
|
||||||
useRag?: boolean; // 默认true
|
useRag?: boolean; // 默认true
|
||||||
useTextToSql?: boolean; // 默认false
|
useTextToSql?: boolean; // 默认false
|
||||||
@@ -118,7 +131,7 @@ export interface SSEChatParams {
|
|||||||
|
|
||||||
/** 上传文件请求参数 */
|
/** 上传文件请求参数 */
|
||||||
export interface UploadDocumentParams {
|
export interface UploadDocumentParams {
|
||||||
projectId: number;
|
projectId: string;
|
||||||
file: File;
|
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[]>>(
|
return http.request<Result<ChatSessionVO[]>>(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/ai/chat/sessions",
|
"/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[]>>(
|
return http.request<Result<KbDocumentVO[]>>(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/ai/kb/documents",
|
"/api/v1/ai/kb/documents",
|
||||||
@@ -229,3 +242,19 @@ export const reindexDocument = (docId: string) => {
|
|||||||
`/api/v1/ai/kb/document/${docId}/reindex`
|
`/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 抽离出来,在此方便维护
|
// 完整版菜单比较多,将 rank 抽离出来,在此方便维护
|
||||||
|
|
||||||
const home = 0, // 平台规定只有 home 路由的 rank 才能为 0 ,所以后端在返回 rank 的时候需要从非 0 开始
|
const home = 0, // 平台规定只有 home 路由的 rank 才能为 0 ,所以后端在返回 rank 的时候需要从非 0 开始
|
||||||
chatai = 1,
|
aiChat = 1,
|
||||||
project = 2,
|
project = 2,
|
||||||
aiChat = 3,
|
chatai = 3,
|
||||||
knowledgeBase = 4,
|
knowledgeBase = 4,
|
||||||
vueflow = 5,
|
vueflow = 5,
|
||||||
ganttastic = 6,
|
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
|
type SSEErrorData
|
||||||
} from "@/api/ai-chat";
|
} from "@/api/ai-chat";
|
||||||
import { getProjectList, type ProjectItem } from "@/api/project";
|
import { getProjectList, type ProjectItem } from "@/api/project";
|
||||||
|
import { createSSEConnection } from "@/utils/sse/chatSSE";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import SendIcon from "~icons/ri/send-plane-fill";
|
import SendIcon from "~icons/ri/send-plane-fill";
|
||||||
@@ -41,8 +42,8 @@ const projects = ref<ProjectItem[]>([]);
|
|||||||
const projectLoading = ref(false);
|
const projectLoading = ref(false);
|
||||||
const showProjectSelect = ref(false);
|
const showProjectSelect = ref(false);
|
||||||
|
|
||||||
// SSE连接
|
// SSE连接关闭函数
|
||||||
let eventSource: EventSource | null = null;
|
let abortSSE: (() => void) | null = null;
|
||||||
|
|
||||||
// 消息容器引用
|
// 消息容器引用
|
||||||
const messagesContainer = ref<HTMLElement | 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;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getChatSessions(
|
const res = await getChatSessions(
|
||||||
projectId || currentSession.value?.projectId || 0
|
projectId || currentSession.value?.projectId || ""
|
||||||
);
|
);
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
sessions.value = res.data || [];
|
sessions.value = res.data || [];
|
||||||
@@ -116,7 +117,7 @@ async function handleProjectSelect(project: ProjectItem) {
|
|||||||
currentSession.value = {
|
currentSession.value = {
|
||||||
sessionId: "",
|
sessionId: "",
|
||||||
sessionTitle: "新对话",
|
sessionTitle: "新对话",
|
||||||
projectId: Number(project.id),
|
projectId: String(project.id),
|
||||||
projectName: project.projectName || "",
|
projectName: project.projectName || "",
|
||||||
lastMessageTime: new Date().toISOString(),
|
lastMessageTime: new Date().toISOString(),
|
||||||
messageCount: 0,
|
messageCount: 0,
|
||||||
@@ -163,81 +164,86 @@ async function sendMessage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 关闭之前的连接
|
// 关闭之前的连接
|
||||||
if (eventSource) {
|
if (abortSSE) {
|
||||||
eventSource.close();
|
abortSSE();
|
||||||
eventSource = null;
|
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) => {
|
// 添加AI回复消息
|
||||||
const data: SSEStartData = JSON.parse(e.data);
|
const assistantMessage: ChatMessageVO = {
|
||||||
if (data.isNewSession || !currentSession.value.sessionId) {
|
id: completeData.messageId,
|
||||||
currentSession.value = {
|
role: "assistant",
|
||||||
...currentSession.value,
|
content: streamingMessage.value,
|
||||||
sessionId: data.sessionId,
|
referencedDocs: streamingReferences.value,
|
||||||
sessionTitle: message.slice(0, 20)
|
tokensUsed: completeData.tokensUsed,
|
||||||
};
|
messageIndex: messages.value.length + 1,
|
||||||
loadSessions(currentSession.value.projectId);
|
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连接
|
// 组件卸载时关闭SSE连接
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (eventSource) {
|
if (abortSSE) {
|
||||||
eventSource.close();
|
abortSSE();
|
||||||
eventSource = null;
|
abortSSE = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
uploadDocument,
|
uploadDocument,
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
reindexDocument,
|
reindexDocument,
|
||||||
|
getDocumentChunks,
|
||||||
type KbDocumentVO,
|
type KbDocumentVO,
|
||||||
type DocumentStatus
|
type DocumentStatus,
|
||||||
|
type DocumentChunkVO
|
||||||
} from "@/api/ai-chat";
|
} from "@/api/ai-chat";
|
||||||
import { getProjectList, type ProjectItem } from "@/api/project";
|
import { getProjectList, type ProjectItem } from "@/api/project";
|
||||||
import dayjs from "dayjs";
|
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 PdfIcon from "~icons/ri/file-pdf-line";
|
||||||
import DocIcon from "~icons/ri/file-word-line";
|
import DocIcon from "~icons/ri/file-word-line";
|
||||||
import TxtIcon from "~icons/ri/file-text-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({
|
defineOptions({
|
||||||
name: "KnowledgeBase"
|
name: "KnowledgeBase"
|
||||||
@@ -31,7 +35,7 @@ const loading = ref(false);
|
|||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const documents = ref<KbDocumentVO[]>([]);
|
const documents = ref<KbDocumentVO[]>([]);
|
||||||
const projects = ref<ProjectItem[]>([]);
|
const projects = ref<ProjectItem[]>([]);
|
||||||
const selectedProjectId = ref<number | undefined>();
|
const selectedProjectId = ref<string | undefined>();
|
||||||
const projectLoading = ref(false);
|
const projectLoading = ref(false);
|
||||||
|
|
||||||
// 上传相关
|
// 上传相关
|
||||||
@@ -39,6 +43,14 @@ const uploadDialogVisible = ref(false);
|
|||||||
const uploadFileList = ref<File[]>([]);
|
const uploadFileList = ref<File[]>([]);
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
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() {
|
async function loadProjects() {
|
||||||
projectLoading.value = true;
|
projectLoading.value = true;
|
||||||
@@ -47,7 +59,7 @@ async function loadProjects() {
|
|||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
projects.value = res.data?.rows || [];
|
projects.value = res.data?.rows || [];
|
||||||
if (projects.value.length > 0 && !selectedProjectId.value) {
|
if (projects.value.length > 0 && !selectedProjectId.value) {
|
||||||
selectedProjectId.value = Number(projects.value[0].id);
|
selectedProjectId.value = String(projects.value[0].id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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(
|
function getStatusType(
|
||||||
status: DocumentStatus
|
status: DocumentStatus
|
||||||
@@ -293,7 +331,7 @@ onMounted(() => {
|
|||||||
v-for="project in projects"
|
v-for="project in projects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:label="project.projectName"
|
:label="project.projectName"
|
||||||
:value="Number(project.id)"
|
:value="project.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,8 +402,17 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<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
|
<el-button
|
||||||
v-if="row.status === 'active' || row.status === 'error'"
|
v-if="row.status === 'active' || row.status === 'error'"
|
||||||
link
|
link
|
||||||
@@ -455,6 +502,83 @@ onMounted(() => {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user