feat(@vben/web-antd): 实现流式消息功能
- 新增 sendChatFlowStream 函数用于发送流式消息 - 重构 WordWorkView组件以支持流式消息显示- 优化消息解析和渲染逻辑,支持文档生成和下载 - 调整 API 接口和数据结构以适应流式消息
This commit is contained in:
parent
91412b45cd
commit
b2b6a202ea
@ -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);
|
||||
|
@ -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({
|
||||
|
@ -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,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user