kagg886 пре 3 месеци
родитељ
комит
4f55a2243d
3 измењених фајлова са 464 додато и 103 уклоњено
  1. 268 0
      src/api/assist/index.ts
  2. 55 0
      src/api/assist/type.ts
  3. 141 103
      src/views/assistant/index.vue

+ 268 - 0
src/api/assist/index.ts

@@ -0,0 +1,268 @@
+import { ChatRequest, ChatResponse } from '/@/api/assist/type'
+import getOrigin from '/@/utils/origin'
+import { getToken } from "/@/utils/auth";
+
+export default {
+	// SSE聊天方法
+	chat: (
+		data:
+		{
+			chatRequest: ChatRequest,
+			// eslint-disable-next-line no-unused-vars
+			onReceive: (resp: ChatResponse) => void,
+
+			// eslint-disable-next-line no-unused-vars
+			onComplete?: (error: Error|undefined)=> void
+		},
+	) => {
+		const {chatRequest, onReceive,onComplete} = data
+
+		//FIXME 需要抹掉
+		chatRequest.modelMcpId = 1 as unknown as number[]
+		chatRequest.modelClassId = 1
+		chatRequest["UserId"] = 10
+
+		// 构建SSE URL
+		const baseURL = getOrigin();
+		const url = `${baseURL}/ai/chat`;
+
+		// 使用fetch API实现SSE POST请求(EventSource不支持POST和自定义headers)
+		const controller = new AbortController();
+
+		// 使用fetch API实现SSE POST请求
+		const startSSE = async () => {
+			try {
+				const headers: Record<string, string> = {
+					'Content-Type': 'application/json',
+					'Accept': 'text/event-stream',
+					'Cache-Control': 'no-cache',
+				};
+
+				// 添加认证token
+				const token = getToken();
+				if (token) {
+					headers['Authorization'] = `Bearer ${token}`;
+				}
+
+				const response = await fetch(url, {
+					method: 'POST',
+					headers,
+					body: JSON.stringify(chatRequest),
+					signal: controller.signal,
+				});
+
+				if (!response.ok) {
+					throw new Error(`HTTP error! status: ${response.status}`);
+				}
+
+				const reader = response.body?.getReader();
+				const decoder = new TextDecoder();
+
+				if (!reader) {
+					throw new Error('Response body is not readable');
+				}
+
+				// 读取SSE流
+				let currentEvent = '';
+				let buffer = '';
+
+				while (true) {
+					const { done, value } = await reader.read();
+
+					if (done) {
+						break;
+					}
+
+					buffer += decoder.decode(value, { stream: true });
+
+					// 按双换行符分割事件块
+					const eventBlocks = buffer.split('\n\n');
+
+					// 保留最后一个可能不完整的事件块
+					buffer = eventBlocks.pop() || '';
+
+					for (const eventBlock of eventBlocks) {
+						if (!eventBlock.trim()) continue;
+
+						const lines = eventBlock.split('\n');
+						let eventType = '';
+						let dataLines: string[] = [];
+
+						// 解析事件块中的每一行
+						for (const line of lines) {
+							const trimmedLine = line.trim();
+
+							if (trimmedLine.startsWith('event:')) {
+								eventType = trimmedLine.substring(6).trim();
+							} else if (trimmedLine.startsWith('data:')) {
+								// 提取data后的内容,保留原始格式(包括可能的空格)
+								const dataContent = line.substring(line.indexOf('data:') + 5);
+								dataLines.push(dataContent);
+							}
+						}
+
+						// 如果有事件类型,更新当前事件
+						if (eventType) {
+							currentEvent = eventType;
+						}
+
+						// 如果有数据行,处理数据
+						if (dataLines.length > 0) {
+							// 将多行data合并,用换行符连接
+							const data = dataLines.join('\n');
+
+							// 根据事件类型处理数据
+							switch (currentEvent) {
+								case 'message': {
+									const messageResponse: ChatResponse = {
+										type: 'message',
+										message: data
+									};
+									onReceive(messageResponse);
+									break;
+								}
+
+								case 'toolcall': {
+									try {
+										const tools = JSON.parse(data);
+										const toolcallResponse: ChatResponse = {
+											type: 'toolcall',
+											request: {
+												name: tools["name"],
+												data: tools["arguments"]
+											}
+										};
+										onReceive(toolcallResponse);
+									} catch (error) {
+										console.error('解析toolcall数据失败:', error, '原始数据:', data);
+									}
+									break;
+								}
+
+								case 'toolres': {
+									try {
+										const tools = JSON.parse(data);
+										const toolresResponse: ChatResponse = {
+											type: 'toolres',
+											response: {
+												name: tools["name"],
+												data: tools["response"]
+											},
+										};
+										onReceive(toolresResponse);
+									} catch (error) {
+										console.error('解析toolres数据失败:', error, '原始数据:', data);
+									}
+									break;
+								}
+
+								default: {
+									// 如果没有明确的事件类型,默认作为消息处理
+									const defaultResponse: ChatResponse = {
+										type: 'message',
+										message: data
+									};
+									onReceive(defaultResponse);
+									break;
+								}
+							}
+						}
+					}
+
+					// 处理buffer中剩余的可能完整的单行事件
+					const remainingLines = buffer.split('\n');
+					const completeLines = remainingLines.slice(0, -1);
+					buffer = remainingLines[remainingLines.length - 1] || '';
+
+					for (const line of completeLines) {
+						const trimmedLine = line.trim();
+
+						if (trimmedLine === '') {
+							continue;
+						}
+
+						if (trimmedLine.startsWith('event:')) {
+							currentEvent = trimmedLine.substring(6).trim();
+							continue;
+						}
+
+						if (trimmedLine.startsWith('data:')) {
+							const data = line.substring(line.indexOf('data:') + 5);
+
+							// 根据事件类型处理数据
+							switch (currentEvent) {
+								case 'message': {
+									const messageResponse: ChatResponse = {
+										type: 'message',
+										message: data
+									};
+									onReceive(messageResponse);
+									break;
+								}
+
+								case 'toolcall': {
+									try {
+										const tools = JSON.parse(data);
+										const toolcallResponse: ChatResponse = {
+											type: 'toolcall',
+											request: {
+												name: tools["name"],
+												data: tools["arguments"]
+											}
+										};
+										onReceive(toolcallResponse);
+									} catch (error) {
+										console.error('解析toolcall数据失败:', error, '原始数据:', data);
+									}
+									break;
+								}
+
+								case 'toolres': {
+									try {
+										const tools = JSON.parse(data);
+										const toolresResponse: ChatResponse = {
+											type: 'toolres',
+											response: {
+												name: tools["name"],
+												data: tools["response"]
+											},
+										};
+										onReceive(toolresResponse);
+									} catch (error) {
+										console.error('解析toolres数据失败:', error, '原始数据:', data);
+									}
+									break;
+								}
+
+								default: {
+									// 如果没有明确的事件类型,默认作为消息处理
+									const defaultResponse: ChatResponse = {
+										type: 'message',
+										message: data
+									};
+									onReceive(defaultResponse);
+									break;
+								}
+							}
+						}
+					}
+				}
+
+				onComplete?.(undefined)
+			} catch (error: any) {
+				if (error.name !== 'AbortError') {
+					console.error('SSE连接错误:', error);
+				}
+				onComplete?.(error)
+			}
+		};
+
+		// 启动SSE连接
+		startSSE();
+
+		// 返回关闭连接的函数
+		return () => {
+			controller.abort();
+		};
+	},
+}

+ 55 - 0
src/api/assist/type.ts

@@ -0,0 +1,55 @@
+/**
+ * {
+ *     "modelClassId": 41,
+ *     "modelMcpId": 56,
+ *     "message": [
+ *         {
+ *             "role": "user",
+ *             "timestamp": 1719242380731,
+ *             "id": "88",
+ *             "content": "分析一下最近10条登录日志"
+ *         }
+ *     ],
+ *     "UserId": 10
+ * }
+ */
+
+// 消息类型定义
+export type Message = {
+	id: number
+	role: 'user' | 'assistant'
+	content: string
+	timestamp: number
+}
+
+export type ChatRequest = {
+	modelClassId?: number
+	modelMcpId?: number[]
+	message: Message[]
+}
+
+export type ChatResponseType = 'message' | 'toolcall' | 'toolres'
+
+export type ChatResponseBase<T = ChatResponseType> = {
+	type: T
+}
+
+export type Text = ChatResponseBase<'message'> & {
+	message: string
+}
+
+export type ToolCallRequest = ChatResponseBase<'toolcall'> & {
+	request: {
+		name: string,
+		data: any
+	}
+}
+
+export type ToolCallResponse = ChatResponseBase<'toolres'> & {
+	response: {
+		name: string,
+		data: any
+	}
+}
+
+export type ChatResponse = Text | ToolCallRequest | ToolCallResponse

+ 141 - 103
src/views/assistant/index.vue

@@ -1,37 +1,16 @@
 <script setup lang="ts">
-import { ref, nextTick, onMounted, reactive } from 'vue'
+import { ref, nextTick, onMounted, computed, onUnmounted, reactive, watch } from 'vue'
 import { Local } from '/@/utils/storage'
 import { User, ChatDotRound, Delete, Edit, Check, Close } from '@element-plus/icons-vue'
 import { MarkdownPlugin } from '/@/components/markdown/type/markdown'
 import EChartsPlugin from '/@/components/markdown/plugins/markdown-it-echarts'
 import Markdown from '/@/components/markdown/Markdown.vue'
+import assist from '/@/api/assist'
+import { ChatResponse, Message } from '/@/api/assist/type'
 
 const plugins: Array<MarkdownPlugin<any>> = [EChartsPlugin()]
 
-const getUserInfos = ref<{
-	avatar: string
-	userName: string
-}>(Local.get('userInfo') || {})
-
-// 消息类型定义
-interface Message {
-	id: number
-	type: 'user' | 'ai'
-	content: string
-	timestamp: Date
-}
-
-// 会话列表
-const conversations = ref<{ id: number; title: string; active: boolean }[]>([])
-
-for (let i = 0; i < 100; i++) {
-	conversations.value.push({ id: i, title: `Summary ${i}`, active: false })
-}
-
-// 编辑状态管理
-const editingConversationId = ref<number | null>(null)
-const editingTitle = ref('')
-
+//聊天管理接口
 // 消息列表
 const messages = ref<Message[]>([])
 
@@ -39,7 +18,7 @@ const messages = ref<Message[]>([])
 const inputMessage = ref('')
 const messagesContainer = ref<HTMLElement>()
 
-// 工具选择数据
+// 工具选择
 const toolOptions = ref([
 	{
 		value: 'code',
@@ -89,7 +68,7 @@ const toolOptions = ref([
 	},
 ])
 
-// 模型选择数据
+// 模型选择
 const modelOptions = ref([
 	{ label: 'Claude Sonnet 4', value: 'claude-sonnet-4' },
 	{ label: 'GPT-4', value: 'gpt-4' },
@@ -101,81 +80,127 @@ const modelOptions = ref([
 const selectedTool = ref([])
 const selectedModel = ref('claude-sonnet-4')
 
-// 对话状态
-const isConversationActive = ref(false)
+const chatInstance = ref<(() => void) | undefined>(undefined)
+onUnmounted(() => chatInstance.value?.())
+// 是否正在对话
+const isConversationActive = computed(() => chatInstance.value !== undefined)
 
 // 发送消息
 const sendMessage = () => {
 	if (!inputMessage.value.trim()) return
 
-	// 设置对话状态为活跃
-	isConversationActive.value = true
-
-	// 添加用户消息
 	messages.value.push({
 		id: Date.now(),
-		type: 'user',
+		role: 'user',
 		content: inputMessage.value,
-		timestamp: new Date(),
+		timestamp: Date.now(),
 	})
 
-	//清空用户聊天框
-	const userMessage = inputMessage.value
-	inputMessage.value = ''
-
-	// 滚动到底部
-	scrollToBottom()
-
-	const ai = reactive<Message>({
-		id: Date.now()+1,
-		type: 'ai',
+	const rtn = reactive<Message>({
+		id: Date.now(),
+		role: 'assistant',
 		content: '',
-		timestamp: new Date()
+		timestamp: Date.now(),
 	})
-
-	// 添加一个回复
-	messages.value.push(ai)
-
-	const delay = async (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
-
-	const action = async () => {
-		const message = `我收到了您的消息:"${userMessage}"。这是一个示例回复,包含markdown格式的内容。
-
-## 回复内容
-
-- 列表项 1
-- 列表项 2
-- 列表项 3
-
-\`\`\`javascript
-console.log('这是代码示例');
-\`\`\``
-		for (let i = 0; i < message.length; i++) {
-			ai.content += message[i]
-			await delay(50)
+	const fe = watch(()=> rtn.content,()=>scrollToBottom())
+
+
+	let toolcall: { name: string; param?: string; value?: string } | undefined = undefined
+	chatInstance.value = assist.chat({
+		chatRequest: {
+			message: messages.value,
+		},
+		onReceive: (resp: ChatResponse) => {
+			switch (resp.type) {
+				case 'message':
+					rtn.content += resp.message
+					break
+				case 'toolcall':
+					toolcall = {
+						name: resp.request.name,
+						param: resp.request.data,
+					}
+					break
+				case 'toolres':
+					if (toolcall !== undefined) {
+						toolcall.value = resp.response.data
+					}
+
+					rtn.content += `
+> ### Tool Call
+>
+> **Params**: \`${toolcall?.name}\`
+>
+> **Value**: \`${toolcall?.value}\`
+
+`
+					toolcall = undefined
+					break
+			}
+		},
+		onComplete: ()=> {
+			chatInstance.value = undefined
+			scrollToBottom()
+			fe()
 		}
-	}
-	action().finally(()=> {
-		isConversationActive.value = false
-		scrollToBottom()
 	})
+	messages.value.push(rtn)
 
-	// setTimeout(() => {
+	//
+	// 	// 添加用户消息
 	// 	messages.value.push({
-	// 		id: Date.now() + 1,
-	// 		type: 'ai',
-	// 		content: ,
+	// 		id: Date.now(),
+	// 		type: 'user',
+	// 		content: inputMessage.value,
 	// 		timestamp: new Date(),
 	// 	})
-	// 	isConversationActive.value = false
+	//
+	// 	//清空用户聊天框
+	// 	const userMessage = inputMessage.value
+	// 	inputMessage.value = ''
+	//
+	// 	// 滚动到底部
 	// 	scrollToBottom()
-	// }, 1000)
+	//
+	// 	const ai = reactive<Message>({
+	// 		id: Date.now()+1,
+	// 		type: 'ai',
+	// 		content: '',
+	// 		timestamp: new Date()
+	// 	})
+	//
+	// 	// 添加一个回复
+	// 	messages.value.push(ai)
+	//
+	// 	const delay = async (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
+	//
+	// 	const action = async () => {
+	// 		const message = `我收到了您的消息:"${userMessage}"。这是一个示例回复,包含markdown格式的内容。
+	//
+	// ## 回复内容
+	//
+	// - 列表项 1
+	// - 列表项 2
+	// - 列表项 3
+	//
+	// \`\`\`javascript
+	// console.log('这是代码示例');
+	// \`\`\``
+	// 		for (let i = 0; i < message.length; i++) {
+	// 			ai.content += message[i]
+	// 			await delay(50)
+	// 		}
+	// 	}
+	// 	action().finally(()=> {
+	// 		isConversationActive.value = false
+	// 		scrollToBottom()
+	// 	})
 }
 
 // 终止对话
 const stopConversation = () => {
-	isConversationActive.value = false
-	// 这里可以添加实际的终止逻辑
+	chatInstance.value?.()
+	chatInstance.value = undefined
 }
 
 // 滚动到底部
@@ -186,20 +211,27 @@ const scrollToBottom = () => {
 		}
 	})
 }
+
+// 会话管理模块
+// 所有会话
+const conversations = ref<{ id: number; title: string }[]>([])
+// 当前活跃会话
+const activeConversationId = ref<number | undefined>(undefined)
+
 // 选择会话
 const selectConversation = (id: number) => {
-	conversations.value.forEach((conv) => {
-		conv.active = conv.id === id
-	})
+	activeConversationId.value = id
 }
 
-// 删除
+// 删除
 const deleteConversation = (id: number) => {
-	console.log('删除对话:', id)
 	// TODO: 实现删除对话逻辑
 }
 
-// 编辑摘要
+// 编辑会话状态管理
+const editingConversationId = ref<number | undefined>(undefined)
+const editingTitle = ref('')
+// 编辑会话摘要
 const editSummary = (id: number) => {
 	const conversation = conversations.value.find((conv) => conv.id === id)
 	if (conversation) {
@@ -216,28 +248,33 @@ const editSummary = (id: number) => {
 		})
 	}
 }
-
 // 确认编辑
 const confirmEdit = (id: number) => {
 	const conversation = conversations.value.find((conv) => conv.id === id)
 	if (conversation && editingTitle.value.trim()) {
 		conversation.title = editingTitle.value.trim()
 		// 清除编辑状态
-		editingConversationId.value = null
+		editingConversationId.value = undefined
 		editingTitle.value = ''
 	}
 }
 
 // 取消编辑
-const cancelEdit = (id: number) => {
+const cancelEdit = () => {
 	// 清除编辑状态
-	editingConversationId.value = null
+	editingConversationId.value = undefined
 	editingTitle.value = ''
 }
 
 onMounted(() => {
 	scrollToBottom()
 })
+
+//杂项
+const getUserInfos = ref<{
+	avatar: string
+	userName: string
+}>(Local.get('userInfo') || {})
 </script>
 
 <template>
@@ -252,7 +289,7 @@ onMounted(() => {
 					v-for="conv in conversations"
 					:key="conv.id"
 					@click="editingConversationId !== conv.id ? selectConversation(conv.id) : () => {}"
-					:class="['conversation-item', { active: conv.active, editing: editingConversationId === conv.id }]"
+					:class="['conversation-item', { active: activeConversationId === conv.id, editing: editingConversationId === conv.id }]"
 				>
 					<!-- 非编辑状态 -->
 					<div v-if="editingConversationId !== conv.id" class="conversation-content">
@@ -261,13 +298,7 @@ onMounted(() => {
 
 					<!-- 编辑状态 -->
 					<div v-else class="conversation-edit-content">
-						<el-input
-							v-model="editingTitle"
-							size="small"
-							@keydown.enter="confirmEdit(conv.id)"
-							@keydown.esc="cancelEdit(conv.id)"
-							class="edit-input"
-						/>
+						<el-input v-model="editingTitle" size="small" @keydown.enter="confirmEdit(conv.id)" @keydown.esc="cancelEdit" class="edit-input" />
 					</div>
 
 					<!-- 操作按钮 -->
@@ -320,9 +351,9 @@ onMounted(() => {
 			<!-- 消息展示区域 -->
 			<div class="messages-container" ref="messagesContainer">
 				<div v-if="messages.length !== 0">
-					<div v-for="message in messages" :key="message.id" :class="['message-wrapper', message.type]">
+					<div v-for="message in messages" :key="message.id" :class="['message-wrapper', message.role]">
 						<!-- AI消息 -->
-						<div v-if="message.type === 'ai'" class="ai-message-container">
+						<div v-if="message.role === 'assistant'" class="ai-message-container">
 							<el-avatar class="message-avatar" :icon="ChatDotRound" />
 							<div class="message-bubble ai-bubble">
 								<Markdown :content="message.content" :plugins="plugins" class="markdown-content" />
@@ -442,7 +473,7 @@ onMounted(() => {
 						>
 							发送
 						</el-button>
-						<el-button v-else type="danger" size="small" @click="stopConversation"> 终止 </el-button>
+						<el-button v-else type="danger" size="small" @click="stopConversation"> 终止</el-button>
 					</div>
 				</div>
 			</div>
@@ -695,7 +726,12 @@ onMounted(() => {
 /* Markdown 内容样式优化 - 接入Element Plus颜色系统 */
 :deep(.markdown-content) {
 	/* 标题样式 */
-	h1, h2, h3, h4, h5, h6 {
+	h1,
+	h2,
+	h3,
+	h4,
+	h5,
+	h6 {
 		margin-top: 24px;
 		margin-bottom: 16px;
 		font-weight: 600;
@@ -759,7 +795,8 @@ onMounted(() => {
 	}
 
 	/* 列表样式 */
-	ul, ol {
+	ul,
+	ol {
 		margin: 16px 0;
 		padding-left: 32px;
 		color: var(--el-text-color-regular);
@@ -778,7 +815,8 @@ onMounted(() => {
 		border: 1px solid var(--el-border-color-light);
 	}
 
-	th, td {
+	th,
+	td {
 		border: 1px solid var(--el-border-color-light);
 		padding: 8px 12px;
 		text-align: left;