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);
}
// 发送流式消息
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);

View File

@ -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({

View File

@ -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<ResultItem[]>([]);
const conversationId = ref('');
const accessStore = useAccessStore();
const layout = {
labelCol: { span: 6 },
wrapperCol: { span: 16 },
@ -201,70 +209,62 @@ function extractDocxInfo(
return null;
}
const startFetching = async () => {
if (projectInfo.value.projectName === '') {
open.value = true;
return;
}
if (content.value === '') {
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,
});
function parseAndRenderStream(text: WorkflowResult) {
let parsedData;
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,
},
content: content.value || '',
parsedData = JSON.parse(text?.data);
} catch (error) {
console.error('Failed to parse event data:', error);
notification.error({
message: '流式数据解析失败',
description: '无法解析来自服务器的数据,请检查网络连接或稍后重试。',
});
content.value = '';
if (conversationId.value === '') {
emit('history', props.item.id);
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);
}
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 {
notification.error({
message: '流式数据解析失败',
description: '无法解析来自服务器的数据,请检查网络连接或稍后重试。',
});
}
}
}
function handleFinalResult(docxInfo: docxInfoResult, aiMessageKey: number) {
if (docxInfo) {
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, [
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);
},
},
{ type: 'primary', onClick: () => openPreviewDrawer('right', url) },
'文档预览',
),
h(
@ -275,7 +275,7 @@ const startFetching = async () => {
onClick: () => {
const link = document.createElement('a');
link.href = url;
link.download = filename as string;
link.download = filename || '';
document.body.append(link);
link.click();
link.remove();
@ -283,19 +283,99 @@ const startFetching = async () => {
},
'文档下载',
),
]),
});
]);
}
} catch (error) {
console.error(error);
}
}
const startFetching = async () => {
//
if (projectInfo.value.projectName === '') {
open.value = true;
return;
}
if (!content.value.trim()) {
notification.warn({
message: '请输入项目内容',
description: '请在输入框中输入项目内容',
});
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 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}`,
},
},
);
// 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);
}
} catch (error) {
console.error('Stream processing failed:', error);
notification.error({
message: '请求失败',
description: 'AI 回答获取失败,请稍后再试',
});
} finally {
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,
});
}
}
});

View File

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

View File

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

View File

@ -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',
},
},