feat(@vben/web-antd): 实现流式消息功能

- 新增 sendChatFlowStream 函数用于发送流式消息
- 重构 WordWorkView组件以支持流式消息显示- 优化消息解析和渲染逻辑,支持文档生成和下载
- 调整 API 接口和数据结构以适应流式消息
This commit is contained in:
Kven 2025-07-03 21:30:44 +08:00
parent 91412b45cd
commit b2b6a202ea
6 changed files with 204 additions and 99 deletions

View File

@ -81,6 +81,14 @@ export async function sendChatflow(
return requestClient.post(`/v1/chat/completions/${params.appid}`, data); 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 // word
export async function sendWord(appId: any, data: ChatflowApi.CompletionsBody) { export async function sendWord(appId: any, data: ChatflowApi.CompletionsBody) {
return requestClient.post(`word/completions/${appId}`, data); return requestClient.post(`word/completions/${appId}`, data);

View File

@ -6,7 +6,7 @@ import type { AppListResult, ModeListResult, PropsHistory } from '../typing';
import { computed, h, ref, watch } from 'vue'; import { computed, h, ref, watch } from 'vue';
import { DeleteOutlined } from '@ant-design/icons-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'; import { Conversations } from 'ant-design-x-vue';
defineOptions({ defineOptions({

View File

@ -7,12 +7,17 @@ import type {
import type { DrawerPlacement } from '@vben/common-ui'; 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 { computed, h, ref, watch } from 'vue';
import { useVbenDrawer } from '@vben/common-ui'; 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 { EditOutlined, UserOutlined } from '@ant-design/icons-vue';
import { import {
@ -34,9 +39,12 @@ import {
Prompts, Prompts,
Sender, Sender,
Welcome, Welcome,
XStream,
} from 'ant-design-x-vue'; } from 'ant-design-x-vue';
import markdownIt from 'markdown-it'; import markdownIt from 'markdown-it';
import { getToken } from '#/api/csrf';
import WordPreview from './word-preview.vue'; import WordPreview from './word-preview.vue';
defineOptions({ name: 'WordWorkView' }); defineOptions({ name: 'WordWorkView' });
@ -94,12 +102,11 @@ const renderMarkdown: BubbleListProps['roles'][string]['messageRender'] = (
// const sleep = () => new Promise((resolve) => setTimeout(resolve, 500)); // const sleep = () => new Promise((resolve) => setTimeout(resolve, 500));
const senderPromptsItems = computed(() => { const senderPromptsItems = computed(() => {
if (typeof props.paramsData !== 'object' || props.paramsData === null) if (typeof props.paramsData !== 'object') return [];
return [];
return Object.keys(props.paramsData).map((key, index) => ({ return props.paramsData.map((item, index) => ({
key: index, key: index,
description: props.paramsData[key], description: item,
})); }));
}); });
@ -167,6 +174,7 @@ const open = ref(false);
const fetchStatus = ref(''); const fetchStatus = ref('');
const resultItems = ref<ResultItem[]>([]); const resultItems = ref<ResultItem[]>([]);
const conversationId = ref(''); const conversationId = ref('');
const accessStore = useAccessStore();
const layout = { const layout = {
labelCol: { span: 6 }, labelCol: { span: 6 },
wrapperCol: { span: 16 }, wrapperCol: { span: 16 },
@ -201,101 +209,173 @@ function extractDocxInfo(
return null; 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 () => { const startFetching = async () => {
//
if (projectInfo.value.projectName === '') { if (projectInfo.value.projectName === '') {
open.value = true; open.value = true;
return; return;
} }
if (content.value === '') { if (!content.value.trim()) {
notification.warn({ notification.warn({
message: '请输入项目内容', message: '请输入项目内容',
description: '请在输入框中输入项目内容', description: '请在输入框中输入项目内容',
}); });
open.value = false;
return; return;
} }
//
open.value = false; open.value = false;
agentRequestLoading.value = true; agentRequestLoading.value = true;
fetchStatus.value = 'fetching'; fetchStatus.value = 'fetching';
//
resultItems.value.push({ resultItems.value.push({
key: resultItems.value.length + 1, key: resultItems.value.length + 1,
role: 'user', role: 'user',
content: content.value, 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 { try {
const res = await props.runChatFlow(props.item.id, { //
userId: userStore.userInfo?.userId || '', const res = await fetch(
conversationId: conversationId.value, `/api/v1/chat/completions/stream/${props.item.id}`,
files: [], {
inputs: { method: 'POST',
projectName: projectInfo.value.projectName, body: JSON.stringify({
projectContext: projectInfo.value.projectContext, userId: userStore.userInfo?.userId || '',
keyAvoidTechOrKeyword: projectInfo.value.projectKeyAvoidTechOrKeyword, conversationId: conversationId.value,
userInitialInnovationPoint: files: [],
projectInfo.value.userInitialInnovationPoint, 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 || '', );
}); // AI
content.value = ''; 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 === '') { if (conversationId.value === '') {
emit('history', props.item.id); 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) { } 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) => { const onPromptsItemClick: PromptsProps['onItemClick'] = (info) => {
content.value = info.data.description; content.value = info.data.description as string;
}; };
// itemMessage resultItems // itemMessage resultItems
@ -315,8 +395,15 @@ watch(
footer: msg.footer, footer: msg.footer,
}); });
} else { } else {
if (msg.content.messageFiles.length > 0) { const docxInfo = extractDocxInfo(msg.content.answer);
const { id, url } = msg.content.messageFiles[0]; 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({ resultItems.value.push({
key: resultItems.value.length + 1, key: resultItems.value.length + 1,
role: msg.role, role: msg.role,
@ -340,7 +427,7 @@ watch(
onClick: () => { onClick: () => {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = id; link.download = filename || '';
document.body.append(link); document.body.append(link);
link.click(); link.click();
link.remove(); link.remove();
@ -350,12 +437,6 @@ watch(
), ),
]), ]),
}); });
} else {
resultItems.value.push({
key: resultItems.value.length + 1,
role: msg.role,
content: msg.content.answer,
});
} }
} }
}); });

View File

@ -18,7 +18,8 @@ import {
getChatflowMessage, getChatflowMessage,
getChatList, getChatList,
getChatParameters, getChatParameters,
sendWord, sendChatFlowStream,
// sendWord,
} from '#/api'; } from '#/api';
import { WordListView, WordWorkView } from './components'; import { WordListView, WordWorkView } from './components';
@ -179,7 +180,7 @@ onMounted(() => {
<WordWorkView <WordWorkView
:item="currentTemp" :item="currentTemp"
:params-data="params" :params-data="params"
:run-chat-flow="sendWord" :run-chat-flow="sendChatFlowStream"
:item-message="itemMessage" :item-message="itemMessage"
:project-info="projectInfo" :project-info="projectInfo"
@history="changeLogs" @history="changeLogs"

View File

@ -20,6 +20,13 @@ interface ResultItem {
role: 'ai' | 'user'; role: 'ai' | 'user';
content: any; content: any;
footer?: any; footer?: any;
typing?: object;
loading?: boolean;
}
interface docxInfoResult {
filename: string | undefined;
url: string;
} }
interface WorkflowContext { interface WorkflowContext {
@ -31,21 +38,27 @@ interface WorkflowContext {
}; };
files: []; files: [];
} }
// interface WorkflowResult {
// conversationId: string;
// answer: string;
// messageFiles?: [
// {
// id: string;
// url: string;
// },
// ];
// data: {
// outputs: {
// result: string;
// };
// };
// }
interface WorkflowResult { interface WorkflowResult {
conversationId: string; data: object;
answer: string;
messageFiles?: [
{
id: string;
url: string;
},
];
data: {
outputs: {
result: string;
};
};
} }
interface Props { interface Props {
itemMessage?: ResultItem[]; itemMessage?: ResultItem[];
item?: WordTempItem; item?: WordTempItem;
@ -53,7 +66,7 @@ interface Props {
runChatFlow?: ( runChatFlow?: (
appId: null | string, appId: null | string,
context: WorkflowContext, context: WorkflowContext,
) => Promise<WorkflowResult>; ) => Promise<ReadableStream>;
projectInfo: { projectInfo: {
projectContext: string; projectContext: string;
projectKeyAvoidTechOrKeyword: string; projectKeyAvoidTechOrKeyword: string;
@ -96,10 +109,12 @@ interface ModeListResult {
export type { export type {
AppListResult, AppListResult,
ChatListResult, ChatListResult,
docxInfoResult,
ModeListResult, ModeListResult,
Props, Props,
PropsHistory, PropsHistory,
ResultItem, ResultItem,
WordHistoryItem, WordHistoryItem,
WordTempItem, WordTempItem,
WorkflowResult,
}; };

View File

@ -39,8 +39,8 @@ export default defineConfig(async () => {
'/v1': { '/v1': {
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/v1/, ''), rewrite: (path) => path.replace(/^\/v1/, ''),
target: 'http://localhost:8081/v1', // target: 'http://localhost:8081/v1',
// target: 'http://43.139.10.64:8082/v1', target: 'http://43.139.10.64:8082/v1',
// target: 'http://192.168.3.238:8081/v1', // target: 'http://192.168.3.238:8081/v1',
}, },
}, },