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);
|
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);
|
||||||
|
@ -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({
|
||||||
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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"
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user