Compare commits

..

1 Commits

Author SHA1 Message Date
3113b9b670 feat(@vben/common-ui): 添加知识库模块
- 新增知识库生成相关路由、页面和组件
- 实现知识库生成的后端接口和前端逻辑
- 添加知识库预览和下载功能
- 优化首页布局,增加知识库生成入口
2025-07-15 21:27:29 +08:00
10 changed files with 793 additions and 3 deletions

View File

@ -86,7 +86,11 @@ export async function sendChatFlowStream(
appId: any,
data: ChatflowApi.CompletionsBody,
) {
return requestClient.post(`/v1/chat/completions/stream/${appId}`, data);
return requestClient.request(`/v1/chat/completions/stream/${appId}`, {
data,
method: 'POST',
responseType: 'stream',
});
}
// word

View File

@ -1,6 +1,6 @@
import type { RouteRecordRaw } from 'vue-router';
import { HugeAi, SvgPPT, SvgSpider, SvgWord } from '@vben/icons';
import { HugeAi, SvgPPT, SvgRepository, SvgSpider, SvgWord } from '@vben/icons';
const routes: RouteRecordRaw[] = [
{
@ -46,6 +46,17 @@ const routes: RouteRecordRaw[] = [
authority: ['ai:ppt'],
},
},
{
name: 'repository',
path: '/ai/repository',
component: () => import('#/views/repository/index.vue'),
meta: {
icon: SvgRepository,
title: '知识库生成',
order: 4,
authority: ['ai:repository'],
},
},
],
},
];

View File

@ -4,7 +4,7 @@ import type { WorkflowItem } from '@vben/common-ui';
import { useRouter } from 'vue-router';
import { WorkflowsView } from '@vben/common-ui';
import { SvgPPT, SvgSpider, SvgWord } from '@vben/icons';
import { SvgPPT, SvgRepository, SvgSpider, SvgWord } from '@vben/icons';
const items: WorkflowItem[] = [
{
@ -25,6 +25,12 @@ const items: WorkflowItem[] = [
description: '自动生成PPT文档',
path: '/ai/ppt',
},
{
icon: SvgRepository,
title: '知识库生成',
description: '自动生成知识库文档',
path: '/ai/repository',
},
];
const router = useRouter();

View File

@ -0,0 +1,2 @@
export { default as RepositoryListView } from './repository-list-view.vue';
export { default as RepositoryWorkView } from './repository-work-view.vue';

View File

@ -0,0 +1,171 @@
<script setup lang="ts">
import type { ConversationsProps } from 'ant-design-x-vue';
import type { MenuItem, Props } from '../typing';
import { computed, ref, watch } from 'vue';
import { Menu } from 'ant-design-vue';
import { Conversations } from 'ant-design-x-vue';
defineOptions({
name: 'RepositoryListView',
});
const props = withDefaults(defineProps<Props>(), {
items: () => [],
temp: () => [],
});
const emit = defineEmits(['click', 'clickMode', 'newConversation']);
const defaultConversationsItems = computed(() => {
return props.items.map((item) => {
return {
key: item.id,
label: item.id,
};
});
});
const conversationsItems = ref(defaultConversationsItems);
const activeKey = ref(defaultConversationsItems.value[0]?.key);
// props.items key=id, label=name, title=name
const transformItems = computed(() => {
return props.temp.map((item) => ({
key: item.id,
label: item.name,
title: item.name,
}));
});
const itemsData = ref<MenuItem[]>([
{
title: '',
label: '',
key: '',
},
]);
const handleMenuClick = (item: any) => {
const selectedItem = itemsData.value.find((i) => i.key === item.key);
if (selectedItem) {
// title -> name, key -> id
const transformedItem = {
name: selectedItem.title,
id: selectedItem.key,
};
emit('clickMode', transformedItem); //
}
};
const handleNewConversation = () => {
emit('newConversation');
};
const onConversationClick: ConversationsProps['onActiveChange'] = (key) => {
activeKey.value = key;
const matchedItem = props.items.find((item) => item.id === key);
if (matchedItem) {
emit('click', matchedItem);
} else {
emit('click', null); //
}
};
const selectedKeys = ref<string[]>([]);
const openKeys = ref([]);
// transformItems itemsData
watch(
() => transformItems.value,
(newVal) => {
itemsData.value = newVal;
},
{ immediate: true },
);
watch(
() => itemsData.value,
(newVal) => {
if (newVal.length > 0 && newVal[0]) {
selectedKeys.value = [newVal[0].key]; //
handleMenuClick(newVal[0]);
}
},
{ immediate: true },
);
</script>
<template>
<div class="menu">
<div class="addBtn">模板</div>
<Menu
v-model:open-keys="openKeys"
v-model:selected-keys="selectedKeys"
mode="vertical"
class="mode"
:items="itemsData"
@click="handleMenuClick"
/>
<div class="addBtn">会话</div>
<Button type="submit" class="newBtn" @click="handleNewConversation">
新建会话
</Button>
<!-- 🌟 会话管理 -->
<Conversations
:items="conversationsItems"
class="conversations"
:active-key="activeKey"
@active-change="onConversationClick"
/>
</div>
</template>
<style scoped lang="less">
.layout {
width: 100%;
min-width: 1400px;
height: 100%;
display: flex;
background: hsl(216deg 21.74% 95.49%);
font-family: AlibabaPuHuiTi, v-deep(var(--ant-font-family)), sans-serif;
border-radius: v-deep(var(--ant-border-radius));
}
.menu {
background: #ffffff;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.mode {
overflow-y: auto;
}
.conversations {
padding: 0 12px;
height: 400px;
overflow-y: auto;
}
.addBtn {
float: left;
//border: 1px solid #1677ff34;
color: #666666;
width: calc(100% - 24px);
margin: 12px 12px 24px 12px;
}
.newBtn {
float: left;
width: calc(100% - 24px);
margin: 12px 12px 12px 12px;
padding: 5px;
border-radius: 5px;
background-color: rgba(19, 118, 163, 0.1);
}
</style>

View File

@ -0,0 +1,60 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import VueOfficePptx from '@vue-office/pptx';
import { message } from 'ant-design-vue';
const isLoading = ref(false); //
const pptx = ref();
const pptStyle = ref({
height: 'calc(100vh - 100px)',
width: '100%',
margin: 'auto',
background: '#ffffff',
});
const renderedHandler = () => {
isLoading.value = false; //
message.success('渲染完成');
};
const errorHandler = () => {
isLoading.value = false; //
message.error('渲染失败');
};
const [Drawer, drawerApi] = useVbenDrawer({
onCancel() {
drawerApi.close();
},
onClosed() {
drawerApi.setState({ overlayBlur: 0, placement: 'right' });
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = drawerApi.getData<Record<string, any>>();
if (data) {
isLoading.value = true; //
pptx.value = data; // pptx
}
// url.value = drawerApi.getData<Record<string, any>>();
}
},
});
</script>
<template>
<Drawer class="w-[880px]" title="文档预览" :footer="false">
<div v-if="isLoading" class="loading-overlay">正在加载文档请稍候...</div>
<VueOfficePptx
:src="pptx"
:style="pptStyle"
@rendered="renderedHandler"
@error="errorHandler"
/>
</Drawer>
</template>
<style scoped>
.pptx-preview-wrapper {
background: #fff;
}
</style>

View File

@ -0,0 +1,336 @@
<script setup lang="ts">
import type { ThoughtChainItem } from 'ant-design-x-vue';
import type { DrawerPlacement } from '@vben/common-ui';
import type { PropsWork, ResultItem } from '../typing';
import type { WorkflowResult } from '#/views/word/typing';
import { h, ref, watch } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { SvgPPT } from '@vben/icons';
import { useAccessStore, useUserStore } from '@vben/stores';
import {
Card,
CardContent,
CardHeader,
CardTitle,
VbenIcon,
} from '@vben-core/shadcn-ui';
import {
CheckCircleOutlined,
InfoCircleOutlined,
LoadingOutlined,
// UserOutlined,
} from '@ant-design/icons-vue';
import { Button, notification, Space } from 'ant-design-vue';
import {
// Attachments,
Sender,
ThoughtChain,
Welcome,
XStream,
} from 'ant-design-x-vue';
import { getToken } from '#/api/csrf';
import PptPreview from './repository-preview.vue';
defineOptions({ name: 'PptWorkView' });
const props = withDefaults(defineProps<PropsWork>(), {
itemMessage: Array,
item: () => {
return {
id: '',
name: '',
};
},
runWorkflow: () => async () => ({
data: {
outputs: {
files: [
{
filename: '',
url: '',
},
],
},
},
}),
});
const userStore = useUserStore();
// ==================== State ====================
const content = ref('');
const agentRequestLoading = ref(false);
const fetchStatus = ref('');
const resultItems = ref<ResultItem[]>([]);
const accessStore = useAccessStore();
const mockServerResponseData: ThoughtChainItem[] = [];
const chainItems = ref<ThoughtChainItem[]>(mockServerResponseData);
// const fetchResult = ref('');
const pptMessage = ref('');
const [PreviewDrawer, previewDrawerApi] = useVbenDrawer({
//
connectedComponent: PptPreview,
// placement: 'left',
});
function openPreviewDrawer(placement: DrawerPlacement = 'right', file?: any) {
previewDrawerApi.setState({ placement }).setData(file.url).open();
}
// ==================== Event ====================
function getStatusIcon(status: ThoughtChainItem['status']) {
switch (status) {
case 'error': {
return h(InfoCircleOutlined);
}
case 'pending': {
return h(LoadingOutlined);
}
case 'success': {
return h(CheckCircleOutlined);
}
default: {
return undefined;
}
}
}
function downloadPpt(files: any) {
// <a>
const link = document.createElement('a');
link.href = files.url; //
link.download = files.filename; //
document.body.append(link); // <a>
link.click(); //
link.remove(); // <a>
}
function updateChainItem(status: ThoughtChainItem['status']) {
if (mockServerResponseData.length > 0) {
mockServerResponseData[mockServerResponseData.length - 1].status = status;
mockServerResponseData[mockServerResponseData.length - 1].icon =
getStatusIcon(status);
mockServerResponseData[mockServerResponseData.length - 1].description =
`status: ${status}`;
}
}
function parseAndRenderStream(text: WorkflowResult) {
let parsedData;
try {
parsedData = JSON.parse(text?.data);
} catch (error) {
console.error('Failed to parse event data:', error);
chainItems.value = [];
notification.error({
message: '流式数据解析失败',
description: '无法解析来自服务器的数据,请检查网络连接或稍后重试。',
});
return;
}
const { event, data } = parsedData;
if (event === 'node_started' && data) {
mockServerResponseData.push({
key: data.nodeId,
title: data.title,
description: data.nodeType,
icon: getStatusIcon('pending'),
status: 'pending',
});
chainItems.value = [...mockServerResponseData];
} else if (
event === 'node_finished' &&
data &&
mockServerResponseData.length > 0
) {
updateChainItem('success');
chainItems.value = [...mockServerResponseData];
if (data.outputs.files?.length > 0) {
pptMessage.value = data.outputs.files[0];
}
}
}
const startFetching = async () => {
resultItems.value = [];
pptMessage.value = '';
agentRequestLoading.value = true;
fetchStatus.value = 'fetching';
try {
const res = await fetch(`/api/v1/workflow/run/stream/${props.item.id}`, {
method: 'POST',
body: JSON.stringify({
userId: userStore.userInfo?.userId || '',
conversationId: '',
files: [],
inputs: {
declarationDoc: 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 })) {
parseAndRenderStream(chunk);
}
} catch (error) {
console.error('Stream processing failed:', error);
notification.error({
message: '请求失败',
description: 'AI 回答获取失败,请稍后再试',
});
}
//
content.value = '';
agentRequestLoading.value = false;
fetchStatus.value = 'completed';
};
// ==================== Nodes ====================
// itemMessage resultItems
watch(
() => props.itemMessage,
(newVal) => {
resultItems.value = [];
chainItems.value = [];
content.value = '';
pptMessage.value = newVal[1]?.content;
},
{ deep: true },
);
</script>
<template>
<div class="layout">
<PreviewDrawer />
<div class="chat">
<div class="chat-left">
<!-- 🌟 输入框 -->
<Sender
:value="content"
class="sender"
placeholder="文档内容"
:auto-size="{ minRows: 6, maxRows: 18 }"
:loading="agentRequestLoading"
:disabled="agentRequestLoading"
@submit="startFetching"
@change="(value) => (content = value)"
>
<template #header>
<Sender.Header :open="true" :title="props.item?.name || ''" />
</template>
</Sender>
</div>
<Space direction="vertical" size:16 class="chat-right">
<!-- 思维链 -->
<ThoughtChain size="large" :items="chainItems">
<ThoughtChainItem
v-for="item in chainItems"
:key="item.key"
style="background: #0d0d10"
/>
</ThoughtChain>
<Welcome
v-if="chainItems.length <= 0 && !pptMessage"
variant="borderless"
icon="https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*s5sNRo5LjfQAAAAAAAAAAAAADgCCAQ/fmt.webp"
title="欢迎使用知识库"
description="请选择模板列表中需要生成文件的模板,输入参数后开始生成生成文件。"
/>
<ADivider v-if="pptMessage && chainItems.length > 0" />
<Card
v-if="pptMessage"
title="PPT"
class="w-5/12 cursor-pointer hover:shadow-xl"
style="background: transparent"
>
<CardHeader>
<CardTitle class="flex-start flex text-xl">
<VbenIcon :icon="SvgPPT" class="size-8 flex-shrink-0" />
知识库已生成,有效时间五分钟
</CardTitle>
</CardHeader>
<CardContent class="flex items-center justify-around">
<Button @click="() => openPreviewDrawer('right', pptMessage)">
预览
</Button>
<Button @click="downloadPpt(pptMessage)">下载</Button>
</CardContent>
</Card>
</Space>
</div>
</div>
</template>
<style scoped lang="less">
.layout {
width: 100%;
//min-width: 1400px;
max-height: 100vh;
display: flex;
flex-direction: column;
padding: 0;
margin: 0 auto;
font-family: AlibabaPuHuiTi, v-deep(var(--ant-font-family)), sans-serif;
border-radius: v-deep(var(--ant-border-radius));
}
.chat {
width: 100%;
height: 100%;
//max-height: 88vh;
//margin: 0 auto;
box-sizing: border-box;
display: flex;
//flex-direction: column;
padding: 0 12px 0 12px;
}
.chat-left {
width: 40%;
height: 100%;
background: #ffffff;
//margin: 0 auto;
box-sizing: border-box;
padding: 12px 12px 0 12px;
}
.chat-right {
width: 60%;
height: auto;
margin: 0 0 0 10px;
padding: 20px;
background: rgb(255, 255, 255);
}
.placeholder {
padding-top: 32px;
text-align: left;
flex: 1;
}
.sender {
background: #ffffff;
width: 100%;
min-height: 100px;
box-shadow: v-deep(var(--ant-box-shadow));
}
</style>

View File

@ -0,0 +1,119 @@
<script lang="ts" setup>
import type { RepositoryTempItem, ResultItem } from './typing';
import { onMounted, ref } from 'vue';
import { notification } from 'ant-design-vue';
import {
getAppListByType,
getWorkflowInfo,
getWorkflowList,
sendPpt,
} from '#/api';
import { RepositoryListView, RepositoryWorkView } from './components';
const temp = ref<RepositoryTempItem[]>([]);
const history = ref([]);
const loading = ref(true);
const itemMessage = ref<ResultItem[]>([]);
const getLogs = async (appid: string, limit: number) => {
loading.value = true;
history.value = await getWorkflowList({
appid,
limit,
});
// history
// history.value = res.reverse();
loading.value = false;
};
const getTemp = async () => {
const res = await getAppListByType(2);
if (Array.isArray(res) && res.length > 0) {
// id name temp
temp.value = res.map(({ id, name }) => ({ id, name }));
}
};
async function handleClick(item: RepositoryTempItem) {
// temp = item;
const res = await getWorkflowInfo({
appid: item?.appId || '',
workflowRunId: item.workflowRunId,
});
itemMessage.value = [];
if (res.inputs) {
itemMessage.value.push({
key: itemMessage.value.length + 1,
role: 'user',
content: res.inputs.declarationDoc,
});
}
if (res.outputs) {
itemMessage.value.push({
key: itemMessage.value.length + 1,
role: 'ai',
content: res.outputs.files[0],
});
}
}
function handleNewConversation() {
//
itemMessage.value = [];
}
const currentTemp = ref<RepositoryTempItem | undefined>();
async function handleClickMode(item: RepositoryTempItem) {
notification.success({
message: `${item.name}`,
description: '已选取',
duration: 3,
});
currentTemp.value = item;
await getLogs(item.id, 5);
}
onMounted(() => {
getTemp();
});
</script>
<template>
<div class="layout">
<div class="lg:w-1/6">
<RepositoryListView
title="选择模板"
:temp="temp"
:items="history"
:loading="loading"
@click-mode="handleClickMode"
@click="handleClick"
@new-conversation="handleNewConversation"
/>
</div>
<RepositoryWorkView
:item="currentTemp"
:run-workflow="sendPpt"
:item-message="itemMessage"
/>
</div>
</template>
<style scoped lang="less">
.layout {
width: 100%;
//min-width: 1400px;
height: 100%;
display: flex;
padding: 0;
background: hsl(216deg 21.74% 95.49%);
font-family: AlibabaPuHuiTi, v-deep(var(--ant-font-family)), sans-serif;
border-radius: v-deep(var(--ant-border-radius));
}
</style>

View File

@ -0,0 +1,79 @@
interface RepositoryTempItem {
id: string;
name: string;
workflowRunId?: string;
appId?: string;
userId?: string;
taskId?: string;
url?: string;
}
interface RepositoryHistoryItem {
id: string;
workflowRun: {
id: string;
};
createdByEndUser: {
id: string;
sessionId: string;
};
}
interface Props {
items?: RepositoryHistoryItem[];
temp: RepositoryTempItem[];
title: string;
loading: boolean;
}
interface ResultItem {
key: number;
role: 'ai' | 'user';
content: any;
footer?: any;
}
interface WorkflowContext {
conversationId: string;
userId: string;
inputs: {
[key: string]: any;
};
files: [];
}
interface WorkflowResult {
data: {
outputs: {
files: [
{
filename: string;
url: string;
},
];
};
};
}
interface PropsWork {
itemMessage?: ResultItem[];
item?: RepositoryTempItem;
runWorkflow?: (appId: any, data: WorkflowContext) => Promise<WorkflowResult>;
}
interface MenuItem {
key: string;
label: string;
title: string;
}
export type {
MenuItem,
Props,
PropsWork,
RepositoryHistoryItem,
RepositoryTempItem,
ResultItem,
WorkflowContext,
WorkflowResult,
};

View File

@ -14,6 +14,7 @@ const SvgAntdvLogoIcon = createIconifyIcon('svg:antdv-logo');
const SvgSpider = createIconifyIcon('svg:la-spider');
const SvgPPT = createIconifyIcon('svg:powerpoint');
const SvgWord = createIconifyIcon('svg:word');
const SvgRepository = createIconifyIcon('iconoir:repository');
export {
SvgAntdvLogoIcon,
@ -26,6 +27,7 @@ export {
SvgCardIcon,
SvgDownloadIcon,
SvgPPT,
SvgRepository,
SvgSpider,
SvgWord,
};