vue-vben-admin/apps/web-antd/src/views/ppt/index.vue
Kven 7020672e57 feat(@vben/web-antd): 新增聊天流和数据抓取功能
- 添加聊天流 API 和相关组件,实现 PPT 模板生成工具界面
- 新增数据抓取工具列表和抓取功能
- 优化模板选择和会话管理逻辑
- 实现消息列表和输入框功能
- 添加文件上传和下载支持
2025-05-04 18:45:03 +08:00

616 lines
15 KiB
Vue

<script setup lang="ts">
import type {
AttachmentsProps,
BubbleListProps,
ConversationsProps,
PromptsProps,
} from 'ant-design-x-vue';
import type { VNode } from 'vue';
import { computed, h, onMounted, ref, watch } from 'vue';
import {
CloudUploadOutlined,
CommentOutlined,
CopyOutlined,
DeleteOutlined,
EllipsisOutlined,
FireOutlined,
HeartOutlined,
PaperClipOutlined,
PlusOutlined,
ReadOutlined,
ShareAltOutlined,
SmileOutlined,
SyncOutlined,
} from '@ant-design/icons-vue';
import VueOfficePptx from '@vue-office/pptx';
import {
Badge,
Button,
Flex,
message,
Space,
theme,
Typography,
} from 'ant-design-vue';
import {
Attachments,
Bubble,
Conversations,
Prompts,
Sender,
useXAgent,
useXChat,
Welcome,
} from 'ant-design-x-vue';
import { getChatList, sendWorkflow } from '#/api';
defineOptions({ name: 'PlaygroundIndependentSetup' });
const { token } = theme.useToken();
const styles = computed(() => {
return {
layout: {
width: '100%',
'min-width': '1000px',
height: '722px',
'border-radius': `${token.value.borderRadius}px`,
display: 'flex',
background: `${token.value.colorBgContainer}`,
'font-family': `AlibabaPuHuiTi, ${token.value.fontFamily}, sans-serif`,
},
menu: {
background: `${token.value.colorBgLayout}80`,
width: '280px',
height: '100%',
display: 'flex',
'flex-direction': 'column',
},
conversations: {
padding: '0 12px',
flex: 1,
'overflow-y': 'auto',
},
chat: {
height: '100%',
width: '100%',
'max-width': '700px',
margin: '0 auto',
'box-sizing': 'border-box',
display: 'flex',
'flex-direction': 'column',
padding: `${token.value.paddingLG}px`,
gap: '16px',
},
messages: {
flex: 1,
},
placeholder: {
'padding-top': '32px',
'text-align': 'left',
flex: 1,
},
sender: {
'box-shadow': token.value.boxShadow,
},
logo: {
display: 'flex',
height: '72px',
'align-items': 'center',
'justify-content': 'start',
padding: '0 24px',
'box-sizing': 'border-box',
},
'logo-img': {
width: '24px',
height: '24px',
display: 'inline-block',
},
'logo-span': {
display: 'inline-block',
margin: '0 8px',
'font-weight': 'bold',
color: token.value.colorText,
'font-size': '16px',
},
addBtn: {
background: '#1677ff0f',
border: '1px solid #1677ff34',
width: 'calc(100% - 24px)',
margin: '0 12px 24px 12px',
},
} as const;
});
const pptx = ref<any>('/static/43b7da46a6ca47e5a9d7710d8dcf499c.pptx');
const [messageApi, contextHolder] = message.useMessage();
const menuConfig: ConversationsProps['menu'] = (conversation) => ({
items: [
{
label: '删除此对话',
key: 'operation3',
icon: h(DeleteOutlined),
danger: true,
},
],
onClick: (menuInfo) => {
messageApi.info(`Click ${conversation.key} - ${menuInfo.key}`);
},
});
const acticeModeKey = ref('1');
// 定义模板数据
const templates = ref([
{ label: '模板1', key: '1' },
{ label: '模板2', key: '2' },
{ label: '模板3', key: '3' },
{ label: '模板34', key: '4' },
{ label: '模板35', key: '5' },
{ label: '模板36', key: '7' },
{ label: '模板37', key: '8' },
{ label: '模板38', key: '9' },
]);
// const sleep = () => new Promise(resolve => setTimeout(resolve, 500))
function renderTitle(icon: VNode, title: string) {
return h(Space, { align: 'start' }, [icon, h('span', title)]);
}
const defaultConversationsItems = [
{
key: '0',
label: 'What is Ant Design X?',
},
];
const placeholderPromptsItems: PromptsProps['items'] = [
{
key: '1',
label: renderTitle(
h(FireOutlined, { style: { color: '#FF4D4F' } }),
'Hot Topics',
),
description: 'What are you interested in?',
children: [
{
key: '1-1',
description: `What's new in X?`,
},
{
key: '1-2',
description: `What's AGI?`,
},
{
key: '1-3',
description: `Where is the doc?`,
},
],
},
{
key: '2',
label: renderTitle(
h(ReadOutlined, { style: { color: '#1890FF' } }),
'Design Guide',
),
description: 'How to design a good product?',
children: [
{
key: '2-1',
icon: h(HeartOutlined),
description: `Know the well`,
},
{
key: '2-2',
icon: h(SmileOutlined),
description: `Set the AI role`,
},
{
key: '2-3',
icon: h(CommentOutlined),
description: `Express the feeling`,
},
],
},
];
const senderPromptsItems: PromptsProps['items'] = [
{
key: '1',
description: 'Hot Topics',
icon: h(FireOutlined, { style: { color: '#FF4D4F' } }),
},
{
key: '2',
description: 'Design Guide',
icon: h(ReadOutlined, { style: { color: '#1890FF' } }),
},
];
const roles: BubbleListProps['roles'] = {
ai: {
placement: 'start',
typing: { step: 5, interval: 20 },
styles: {
content: {
borderRadius: '16px',
},
},
},
local: {
placement: 'end',
variant: 'shadow',
},
};
// ==================== State ====================
const headerOpen = ref(false);
const content = ref('');
const conversationsItems = ref(defaultConversationsItems);
const activeKey = ref(defaultConversationsItems[0]?.key);
const attachedFiles = ref<AttachmentsProps['items']>([]);
const agentRequestLoading = ref(false);
// ==================== Runtime ====================
const [agent] = useXAgent({
request: async ({ message }, { onSuccess }) => {
agentRequestLoading.value = true;
const res = await sendWorkflow(
{
appid: 'baca08c1-e92b-4dc9-a445-3584803f54d4',
},
{
userId: '1562',
conversationId: '',
files: [],
inputs: {
declarationDoc: message,
},
},
);
agentRequestLoading.value = false;
if (res.data.data.outputs.result) {
onSuccess(`下载链接:${res.data.data.outputs.result}`);
const url = new URL(res.data.data.outputs.result);
pptx.value = url.pathname;
} else {
onSuccess('发生异常,可以输入更多信息再让我来回答或重试。');
}
},
});
const { onRequest, messages, setMessages } = useXChat({
agent: agent?.value,
});
watch(
activeKey,
() => {
if (activeKey.value !== undefined) {
setMessages([]);
}
},
{ immediate: true },
);
// ==================== Event ====================
function onSubmit(nextContent: string) {
if (!nextContent) return;
onRequest(nextContent);
// sendMessage(nextContent)
content.value = '';
}
const onPromptsItemClick: PromptsProps['onItemClick'] = (info) => {
onRequest(info.data.description as string);
};
function onAddConversation() {
conversationsItems.value = [
...conversationsItems.value,
{
key: `${conversationsItems.value.length}`,
label: `New Conversation ${conversationsItems.value.length}`,
},
];
activeKey.value = `${conversationsItems.value.length}`;
}
const onConversationClick: ConversationsProps['onActiveChange'] = (key) => {
activeKey.value = key;
};
const handleFileChange: AttachmentsProps['onChange'] = (info) =>
(attachedFiles.value = info.fileList);
// ==================== Nodes ====================
const placeholderNode = computed(() =>
h(
Space,
{ direction: 'vertical', size: 16, style: styles.value.placeholder },
[
h(Welcome, {
variant: 'borderless',
icon: 'https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*s5sNRo5LjfQAAAAAAAAAAAAADgCCAQ/fmt.webp',
title: "Hello, I'm Ant Design X",
description:
'Base on Ant Design, AGI product interface solution, create a better intelligent vision~',
extra: h(Space, {}, [
h(Button, { icon: h(ShareAltOutlined) }),
h(Button, { icon: h(EllipsisOutlined) }),
]),
}),
h(Prompts, {
title: 'Do you want?',
items: placeholderPromptsItems,
styles: {
list: {
width: '100%',
},
item: {
flex: 1,
},
},
onItemClick: onPromptsItemClick,
}),
],
),
);
const items = computed<BubbleListProps['items']>(() => {
if (messages.value.length === 0) {
return [{ content: placeholderNode, variant: 'borderless' }];
}
return messages.value.map(({ id, message, status }) => ({
key: id,
loading: status === 'loading',
role: status === 'local' ? 'local' : 'ai',
content: message,
}));
});
// 自定义代码
// 获取会话记录
const getConversationList = async () => {
const res = await getChatList(
{
appid: '363b9580-ae60-4a40-ae7d-ec434e86e326',
},
{
userId: '1562',
lastId: '4dfa17e1-364a-4c98-af5d-1109c8f28212',
sortBy: '',
limit: '5',
},
);
if (res.status === 200) {
conversationsItems.value = res.data.data.map((item: any) => {
return {
key: item.id,
label: item.name,
};
});
activeKey.value = res.data.data[0].id;
}
};
const showQuoteButton = ref(false);
const selectedText = ref('');
const quoteButtonStyle = ref({
position: 'absolute',
top: '0',
left: '0',
display: 'none',
});
const handleTextSelection = (event: any) => {
const selection = window.getSelection();
if (selection && selection.toString().trim() !== '') {
selectedText.value = selection.toString();
// 获取鼠标位置
const mouseX = event.pageX;
const mouseY = event.pageY;
// 设置按钮位置
quoteButtonStyle.value = {
position: 'fixed',
top: `${mouseY}px`,
left: `${mouseX}px`,
display: 'block',
};
showQuoteButton.value = true;
} else {
showQuoteButton.value = false;
}
};
const handleQuoteClick = () => {
// 处理引用逻辑,比如将选中的文本添加到引用框中
content.value = selectedText.value;
showQuoteButton.value = false;
};
// 定义 pptx 的样式
const pptStyle = ref({
height: 'calc(100vh - 150px)',
width: '40%',
margin: '30px 20px',
});
// 监听窗口大小变化
const handleResize = () => {
// 动态调整宽度和高度
pptStyle.value = {
height: 'calc(100vh - 150px)', // 保持高度不变
width: '40%', // 宽度为窗口宽度的 40%
margin: '30px 20px',
};
};
// 监听模板选择事件
const onModeClick: ConversationsProps['onActiveChange'] = (key) => {
acticeModeKey.value = key;
};
const renderedHandler = () => {
console.warn('渲染完成');
};
const errorHandler = () => {
console.error('渲染失败');
};
onMounted(() => {
getConversationList();
window.addEventListener('resize', handleResize);
});
</script>
<template>
<contextHolder />
<div :style="styles.layout" style="height: calc(100vh - 70px)">
<div :style="styles.menu">
<!-- 🌟 Logo -->
<div :style="styles.logo">
<img
src="https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*eco6RrQhxbMAAAAAAAAAAAAADgCCAQ/original"
draggable="false"
alt="logo"
:style="styles['logo-img']"
/>
<span :style="styles['logo-span']">PPT模板生成</span>
</div>
<!-- 🌟 添加会话 -->
<Button type="link" :style="styles.addBtn" @click="onAddConversation">
<PlusOutlined />
新建会话
</Button>
<!-- 🌟 会话管理 -->
<Conversations
:items="conversationsItems"
:style="styles.conversations"
:active-key="activeKey"
:menu="menuConfig"
@active-change="onConversationClick"
/>
<!-- 🌟 会话管理 -->
<Typography.Title :level="5" style="margin: 0 0 8px">
选择模板
</Typography.Title>
<Conversations
:items="templates"
:style="styles.conversations"
:active-key="acticeModeKey"
:menu="menuConfig"
@active-change="onModeClick"
/>
</div>
<div :style="styles.chat" style="margin-left: 20px">
<!-- 🌟 消息列表 -->
<Bubble.List
:items="items"
:roles="roles"
:style="styles.messages"
@mouseup="handleTextSelection"
>
<template #footer>
<Space :size="token.paddingXXS">
<Button type="text" size="small" :icon="h(SyncOutlined)" />
<Button type="text" size="small" :icon="h(CopyOutlined)" />
</Space>
</template>
</Bubble.List>
<a-button
v-if="showQuoteButton"
:style="quoteButtonStyle"
@click="handleQuoteClick"
>
引用
</a-button>
<!-- 🌟 提示词 -->
<Prompts :items="senderPromptsItems" @item-click="onPromptsItemClick" />
<!-- 🌟 输入框 -->
<Sender
:value="content"
:style="styles.sender"
:loading="agentRequestLoading"
@submit="onSubmit"
@change="(value) => (content = value)"
>
<template #prefix>
<Badge :dot="attachedFiles.length > 0 && !headerOpen">
<Button type="text" @click="() => (headerOpen = !headerOpen)">
<template #icon>
<PaperClipOutlined />
</template>
</Button>
</Badge>
</template>
<template #header>
<Sender.Header
title="Attachments"
:open="headerOpen"
:styles="{ content: { padding: 0 } }"
@open-change="(open) => (headerOpen = open)"
>
<Attachments
:before-upload="() => false"
:items="attachedFiles"
@change="handleFileChange"
>
<template #placeholder="type">
<Flex
v-if="type && type.type === 'inline'"
align="center"
justify="center"
vertical
gap="2"
>
<Typography.Text style="font-size: 30px; line-height: 1">
<CloudUploadOutlined />
</Typography.Text>
<Typography.Title
:level="5"
style="margin: 0; font-size: 14px; line-height: 1.5"
>
Upload files
</Typography.Title>
<Typography.Text type="secondary">
Click or drag files to this area to upload
</Typography.Text>
</Flex>
<Typography.Text v-if="type && type.type === 'drop'">
Drop file here
</Typography.Text>
</template>
</Attachments>
</Sender.Header>
</template>
</Sender>
</div>
<VueOfficePptx
:src="pptx"
:style="pptStyle"
@rendered="renderedHandler"
@error="errorHandler"
/>
</div>
</template>