From b2b6a202eafd2635f5e58fd3c717250c39b441e7 Mon Sep 17 00:00:00 2001 From: Kven <2955163637@qq.com> Date: Thu, 3 Jul 2025 21:30:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(@vben/web-antd):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E6=B6=88=E6=81=AF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 sendChatFlowStream 函数用于发送流式消息 - 重构 WordWorkView组件以支持流式消息显示- 优化消息解析和渲染逻辑,支持文档生成和下载 - 调整 API 接口和数据结构以适应流式消息 --- apps/web-antd/src/api/core/chatflow.ts | 8 + .../views/word/components/word-list-view.vue | 2 +- .../views/word/components/word-work-view.vue | 241 ++++++++++++------ apps/web-antd/src/views/word/index.vue | 5 +- apps/web-antd/src/views/word/typing.ts | 43 +++- apps/web-antd/vite.config.mts | 4 +- 6 files changed, 204 insertions(+), 99 deletions(-) diff --git a/apps/web-antd/src/api/core/chatflow.ts b/apps/web-antd/src/api/core/chatflow.ts index 172ee2f..ed39adb 100644 --- a/apps/web-antd/src/api/core/chatflow.ts +++ b/apps/web-antd/src/api/core/chatflow.ts @@ -81,6 +81,14 @@ export async function sendChatflow( return requestClient.post(`/v1/chat/completions/${params.appid}`, data); } +// 发送流式消息 +export async function sendChatFlowStream( + appId: any, + data: ChatflowApi.CompletionsBody, +) { + return requestClient.post(`/v1/chat/completions/stream/${appId}`, data); +} + // word export async function sendWord(appId: any, data: ChatflowApi.CompletionsBody) { return requestClient.post(`word/completions/${appId}`, data); diff --git a/apps/web-antd/src/views/word/components/word-list-view.vue b/apps/web-antd/src/views/word/components/word-list-view.vue index f381b3b..e8edcef 100644 --- a/apps/web-antd/src/views/word/components/word-list-view.vue +++ b/apps/web-antd/src/views/word/components/word-list-view.vue @@ -6,7 +6,7 @@ import type { AppListResult, ModeListResult, PropsHistory } from '../typing'; import { computed, h, ref, watch } from 'vue'; import { DeleteOutlined } from '@ant-design/icons-vue'; -import { Menu } from 'ant-design-vue'; +import { Button, Menu } from 'ant-design-vue'; import { Conversations } from 'ant-design-x-vue'; defineOptions({ diff --git a/apps/web-antd/src/views/word/components/word-work-view.vue b/apps/web-antd/src/views/word/components/word-work-view.vue index 8de4c98..b2952cf 100644 --- a/apps/web-antd/src/views/word/components/word-work-view.vue +++ b/apps/web-antd/src/views/word/components/word-work-view.vue @@ -7,12 +7,17 @@ import type { import type { DrawerPlacement } from '@vben/common-ui'; -import type { Props, ResultItem } from '../typing'; +import type { + docxInfoResult, + Props, + ResultItem, + WorkflowResult, +} from '../typing'; import { computed, h, ref, watch } from 'vue'; import { useVbenDrawer } from '@vben/common-ui'; -import { useUserStore } from '@vben/stores'; +import { useAccessStore, useUserStore } from '@vben/stores'; import { EditOutlined, UserOutlined } from '@ant-design/icons-vue'; import { @@ -34,9 +39,12 @@ import { Prompts, Sender, Welcome, + XStream, } from 'ant-design-x-vue'; import markdownIt from 'markdown-it'; +import { getToken } from '#/api/csrf'; + import WordPreview from './word-preview.vue'; defineOptions({ name: 'WordWorkView' }); @@ -94,12 +102,11 @@ const renderMarkdown: BubbleListProps['roles'][string]['messageRender'] = ( // const sleep = () => new Promise((resolve) => setTimeout(resolve, 500)); const senderPromptsItems = computed(() => { - if (typeof props.paramsData !== 'object' || props.paramsData === null) - return []; + if (typeof props.paramsData !== 'object') return []; - return Object.keys(props.paramsData).map((key, index) => ({ + return props.paramsData.map((item, index) => ({ key: index, - description: props.paramsData[key], + description: item, })); }); @@ -167,6 +174,7 @@ const open = ref(false); const fetchStatus = ref(''); const resultItems = ref([]); const conversationId = ref(''); +const accessStore = useAccessStore(); const layout = { labelCol: { span: 6 }, wrapperCol: { span: 16 }, @@ -201,101 +209,173 @@ function extractDocxInfo( return null; } +function parseAndRenderStream(text: WorkflowResult) { + let parsedData; + try { + parsedData = JSON.parse(text?.data); + } catch (error) { + console.error('Failed to parse event data:', error); + notification.error({ + message: '流式数据解析失败', + description: '无法解析来自服务器的数据,请检查网络连接或稍后重试。', + }); + return; + } + + const { event, answer } = parsedData; + + if (event === 'message' && answer) { + // 查找所有 role 为 'ai' 的项 + const aiMessages = resultItems.value.filter((item) => item.role === 'ai'); + if (aiMessages.length > 0) { + // 获取最后一个 AI 消息项 + const lastAIMessage = aiMessages[aiMessages.length - 1]; + const lastIndex = resultItems.value.indexOf(lastAIMessage); + const docxInfo = extractDocxInfo(answer); + + if ( + lastIndex !== -1 && + resultItems.value[lastIndex] && + docxInfo === null + ) { + resultItems.value[lastIndex].loading = false; + resultItems.value[lastIndex].content += answer; + conversationId.value = parsedData.conversationId; + } else if (docxInfo) { + handleFinalResult(docxInfo, lastIndex); + } + } else { + notification.error({ + message: '流式数据解析失败', + description: '无法解析来自服务器的数据,请检查网络连接或稍后重试。', + }); + } + } +} + +function handleFinalResult(docxInfo: docxInfoResult, aiMessageKey: number) { + if (docxInfo) { + const { filename, url } = docxInfo; + const index = + resultItems.value.findIndex((item) => item.key === aiMessageKey) + 1; + if (index > -1 && resultItems.value[index]) { + resultItems.value[index].loading = false; + resultItems.value[index].content = '文档已生成'; + resultItems.value[index].footer = h(Flex, null, [ + h( + Button, + { type: 'primary', onClick: () => openPreviewDrawer('right', url) }, + '文档预览', + ), + h( + Button, + { + type: 'primary', + style: { marginLeft: '10px' }, + onClick: () => { + const link = document.createElement('a'); + link.href = url; + link.download = filename || ''; + document.body.append(link); + link.click(); + link.remove(); + }, + }, + '文档下载', + ), + ]); + } + } +} + const startFetching = async () => { + // 表单校验 if (projectInfo.value.projectName === '') { open.value = true; return; } - if (content.value === '') { + if (!content.value.trim()) { notification.warn({ message: '请输入项目内容', description: '请在输入框中输入项目内容', }); - open.value = false; return; } + + // 初始化状态 open.value = false; agentRequestLoading.value = true; fetchStatus.value = 'fetching'; + + // 添加用户消息 resultItems.value.push({ key: resultItems.value.length + 1, role: 'user', content: content.value, }); + const aiMessageKey = resultItems.value.length + 1; + resultItems.value.push({ + key: aiMessageKey, + role: 'ai', + content: '', + typing: { step: 2, interval: 50 }, + loading: true, + }); + try { - const res = await props.runChatFlow(props.item.id, { - userId: userStore.userInfo?.userId || '', - conversationId: conversationId.value, - files: [], - inputs: { - projectName: projectInfo.value.projectName, - projectContext: projectInfo.value.projectContext, - keyAvoidTechOrKeyword: projectInfo.value.projectKeyAvoidTechOrKeyword, - userInitialInnovationPoint: - projectInfo.value.userInitialInnovationPoint, + // 发起流式请求 + const res = await fetch( + `/api/v1/chat/completions/stream/${props.item.id}`, + { + method: 'POST', + body: JSON.stringify({ + userId: userStore.userInfo?.userId || '', + conversationId: conversationId.value, + files: [], + inputs: { + projectName: projectInfo.value.projectName, + projectContext: projectInfo.value.projectContext, + keyAvoidTechOrKeyword: + projectInfo.value.projectKeyAvoidTechOrKeyword, + userInitialInnovationPoint: + projectInfo.value.userInitialInnovationPoint, + }, + content: content.value || '', + }), + headers: { + 'Accept-Language': 'zh-CN', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': getToken() || '', + Authorization: `Bearer ${accessStore.accessToken}`, + }, }, - content: content.value || '', - }); - content.value = ''; + ); + // 实时更新 AI 消息 + for await (const chunk of XStream({ readableStream: res.body })) { + // console.log('chunk', chunk.data); + // const text = new TextDecoder().decode(chunk.data); + parseAndRenderStream(chunk); + } + + // 触发历史记录更新 if (conversationId.value === '') { emit('history', props.item.id); } - conversationId.value = res.conversationId; - const docxInfo = extractDocxInfo(res.answer); - if (docxInfo === null) { - resultItems.value.push({ - key: resultItems.value.length + 1, - role: 'ai', - content: res.answer, - }); - } else { - const { filename, url } = docxInfo; - // const { id, url } = res.messageFiles[0]; - resultItems.value.push({ - key: resultItems.value.length + 1, - role: 'ai', - content: '文档已生成', - footer: h(Flex, null, [ - h( - Button, - { - type: 'primary', - onClick: () => { - openPreviewDrawer('right', url); - }, - }, - '文档预览', - ), - h( - Button, - { - type: 'primary', - style: { marginLeft: '10px' }, - onClick: () => { - const link = document.createElement('a'); - link.href = url; - link.download = filename as string; - document.body.append(link); - link.click(); - link.remove(); - }, - }, - '文档下载', - ), - ]), - }); - } } catch (error) { - console.error(error); + console.error('Stream processing failed:', error); + notification.error({ + message: '请求失败', + description: 'AI 回答获取失败,请稍后再试', + }); + } finally { + agentRequestLoading.value = false; + fetchStatus.value = 'completed'; } - - agentRequestLoading.value = false; - fetchStatus.value = 'completed'; }; const onPromptsItemClick: PromptsProps['onItemClick'] = (info) => { - content.value = info.data.description; + content.value = info.data.description as string; }; // 监听 itemMessage 变化并更新 resultItems @@ -315,8 +395,15 @@ watch( footer: msg.footer, }); } else { - if (msg.content.messageFiles.length > 0) { - const { id, url } = msg.content.messageFiles[0]; + const docxInfo = extractDocxInfo(msg.content.answer); + if (docxInfo === null) { + resultItems.value.push({ + key: resultItems.value.length + 1, + role: msg.role, + content: msg.content.answer, + }); + } else { + const { filename, url } = docxInfo; resultItems.value.push({ key: resultItems.value.length + 1, role: msg.role, @@ -340,7 +427,7 @@ watch( onClick: () => { const link = document.createElement('a'); link.href = url; - link.download = id; + link.download = filename || ''; document.body.append(link); link.click(); link.remove(); @@ -350,12 +437,6 @@ watch( ), ]), }); - } else { - resultItems.value.push({ - key: resultItems.value.length + 1, - role: msg.role, - content: msg.content.answer, - }); } } }); diff --git a/apps/web-antd/src/views/word/index.vue b/apps/web-antd/src/views/word/index.vue index 6c91957..38d7507 100644 --- a/apps/web-antd/src/views/word/index.vue +++ b/apps/web-antd/src/views/word/index.vue @@ -18,7 +18,8 @@ import { getChatflowMessage, getChatList, getChatParameters, - sendWord, + sendChatFlowStream, + // sendWord, } from '#/api'; import { WordListView, WordWorkView } from './components'; @@ -179,7 +180,7 @@ onMounted(() => { Promise; + ) => Promise; projectInfo: { projectContext: string; projectKeyAvoidTechOrKeyword: string; @@ -96,10 +109,12 @@ interface ModeListResult { export type { AppListResult, ChatListResult, + docxInfoResult, ModeListResult, Props, PropsHistory, ResultItem, WordHistoryItem, WordTempItem, + WorkflowResult, }; diff --git a/apps/web-antd/vite.config.mts b/apps/web-antd/vite.config.mts index 47be102..2159b02 100644 --- a/apps/web-antd/vite.config.mts +++ b/apps/web-antd/vite.config.mts @@ -39,8 +39,8 @@ export default defineConfig(async () => { '/v1': { changeOrigin: true, rewrite: (path) => path.replace(/^\/v1/, ''), - target: 'http://localhost:8081/v1', - // target: 'http://43.139.10.64:8082/v1', + // target: 'http://localhost:8081/v1', + target: 'http://43.139.10.64:8082/v1', // target: 'http://192.168.3.238:8081/v1', }, },