feat(ai): 添加 AI 写作、知识库、思维导图和工作流功能

- 新增 AI 写作功能,包括示例点击、重置和停止流等功能
- 实现 AI 知识库管理,支持创建、编辑和删除知识库
- 添加 AI 思维导图功能,支持预览和管理思维导图
- 实现 AI 工作流管理,支持创建、编辑和删除工作流
- 优化 API 调用,使用 Vben 组件库和 Vue 3 相关特性
pull/145/head
gjd 2025-06-10 18:03:16 +08:00
parent 54066859c5
commit a4e44379e8
29 changed files with 20774 additions and 76 deletions

View File

@ -0,0 +1,52 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiKnowledgeDocumentApi {
export interface KnowledgeDocumentVO {
id: number; // 编号
knowledgeId: number; // 知识库编号
name: string; // 文档名称
contentLength: number; // 字符数
tokens: number; // token 数
segmentMaxTokens: number; // 分片最大 token 数
retrievalCount: number; // 召回次数
status: number; // 是否启用
}
}
// 查询知识库文档分页
export function getKnowledgeDocumentPage(params: PageParam) {
return requestClient.get<
PageResult<AiKnowledgeDocumentApi.KnowledgeDocumentVO>
>('/ai/knowledge/document/page', { params });
}
// 查询知识库文档详情
export function getKnowledge(id: number) {
return requestClient.get<AiKnowledgeDocumentApi.KnowledgeDocumentVO>(
`/ai/knowledge/document/get?id=${id}`,
);
}
// 新增知识库文档(单个)
export function createKnowledge(data: any) {
return requestClient.post('/ai/knowledge/document/create', data);
}
// 新增知识库文档(多个)
export function createKnowledgeDocumentList(data: any) {
return requestClient.post('/ai/knowledge/document/create-list', data);
}
// 修改知识库文档
export function updateKnowledge(data: any) {
return requestClient.put('/ai/knowledge/document/update', data);
}
// 修改知识库文档状态
export function updateKnowledgeDocumentStatus(data: any) {
return requestClient.put('/ai/knowledge/document/update-status', data);
}
// 删除知识库文档
export function deleteKnowledgeDocument(id: number) {
return requestClient.delete(`/ai/knowledge/document/delete?id=${id}`);
}

View File

@ -0,0 +1,76 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace AiKnowledgeSegmentApi {
// AI 知识库分段 VO
export interface KnowledgeSegmentVO {
id: number; // 编号
documentId: number; // 文档编号
knowledgeId: number; // 知识库编号
vectorId: string; // 向量库编号
content: string; // 切片内容
contentLength: number; // 切片内容长度
tokens: number; // token 数量
retrievalCount: number; // 召回次数
status: number; // 文档状态
createTime: number; // 创建时间
}
}
// 查询知识库分段分页
export function getKnowledgeSegmentPage(params: PageParam) {
return requestClient.get<
PageResult<AiKnowledgeSegmentApi.KnowledgeSegmentVO>
>('/ai/knowledge/segment/page', { params });
}
// 查询知识库分段详情
export function getKnowledge(id: number) {
return requestClient.get<AiKnowledgeSegmentApi.KnowledgeSegmentVO>(
`/ai/knowledge/segment/get?id=${id}`,
);
}
// 新增知识库分段
export function createKnowledgeSegment(
data: AiKnowledgeSegmentApi.KnowledgeSegmentVO,
) {
return requestClient.post('/ai/knowledge/segment/create', data);
}
// 修改知识库分段
export function updateKnowledgeSegment(
data: AiKnowledgeSegmentApi.KnowledgeSegmentVO,
) {
return requestClient.put('/ai/knowledge/segment/update', data);
}
// 修改知识库分段状态
export function updateKnowledgeSegmentStatus(data: any) {
return requestClient.put('/ai/knowledge/segment/update-status', data);
}
// 删除知识库分段
export function deleteKnowledgeSegment(id: number) {
return requestClient.delete(`/ai/knowledge/segment/delete?id=${id}`);
}
// 切片内容
export function splitContent(url: string, segmentMaxTokens: number) {
return requestClient.get('/ai/knowledge/segment/split', {
params: { url, segmentMaxTokens },
});
}
// 获取文档处理列表
export function getKnowledgeSegmentProcessList(documentIds: number[]) {
return requestClient.get('/ai/knowledge/segment/get-process-list', {
params: { documentIds: documentIds.join(',') },
});
}
// 搜索知识库分段
export function searchKnowledgeSegment(params: any) {
return requestClient.get('/ai/knowledge/segment/search', {
params,
});
}

View File

@ -0,0 +1,29 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export function getWorkflowPage(params: PageParam) {
return requestClient.get<PageResult<any>>('/ai/workflow/page', {
params,
});
}
export const getWorkflow = (id: number | string) => {
return requestClient.get(`/ai/workflow/get?id=${id}`);
};
export const createWorkflow = (data: any) => {
return requestClient.post('/ai/workflow/create', data);
};
export const updateWorkflow = (data: any) => {
return requestClient.put('/ai/workflow/update', data);
};
export const deleteWorkflow = (id: number | string) => {
return requestClient.delete(`/ai/workflow/delete?id=${id}`);
};
export const testWorkflow = (data: any) => {
return requestClient.post('/ai/workflow/test', data);
};

View File

@ -50,13 +50,19 @@ export namespace AiWriteApi {
}
}
export function writeStream(
data: any,
onClose: any,
onMessage: any,
onError: any,
ctrl: any,
) {
export function writeStream({
data,
onClose,
onMessage,
onError,
ctrl,
}: {
ctrl: AbortController;
data: Partial<AiWriteApi.WriteVO>;
onClose?: (...args: any[]) => void;
onError?: (...args: any[]) => void;
onMessage?: (res: any) => void;
}) {
const token = accessStore.accessToken;
return fetchEventSource(
`${import.meta.env.VITE_BASE_URL}/ai/write/generate-stream`,

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import type { Item } from './ui';
import { onMounted, onUnmounted, ref } from 'vue';
import { Tinyflow as TinyflowNative } from './ui';
import './ui/index.css';
const props = defineProps<{
className?: string;
data?: Record<string, any>;
provider?: {
internal?: () => Item[] | Promise<Item[]>;
knowledge?: () => Item[] | Promise<Item[]>;
llm?: () => Item[] | Promise<Item[]>;
};
style?: Record<string, string>;
}>();
const divRef = ref<HTMLDivElement | null>(null);
let tinyflow: null | TinyflowNative = null;
// provider
const defaultProvider = {
llm: () => [] as Item[],
knowledge: () => [] as Item[],
internal: () => [] as Item[],
};
onMounted(() => {
if (divRef.value) {
// provider props.provider
const mergedProvider = {
...defaultProvider,
...props.provider,
};
tinyflow = new TinyflowNative({
element: divRef.value as Element,
data: props.data || {},
provider: mergedProvider,
});
}
});
onUnmounted(() => {
if (tinyflow) {
tinyflow.destroy();
tinyflow = null;
}
});
const getData = () => {
if (tinyflow) {
return tinyflow.getData();
}
console.warn('Tinyflow instance is not initialized');
return null;
};
defineExpose({
getData,
});
</script>
<template>
<div
ref="divRef"
class="tinyflow"
:class="[className]"
:style="style"
style="height: 100%"
></div>
</template>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
import { Edge } from '@xyflow/svelte';
import { Node as Node_2 } from '@xyflow/svelte';
import { useSvelteFlow } from '@xyflow/svelte';
import { Viewport } from '@xyflow/svelte';
export declare type Item = {
value: number | string;
label: string;
children?: Item[];
};
export declare class Tinyflow {
private options;
private rootEl;
private svelteFlowInstance;
constructor(options: TinyflowOptions);
private _init;
private _setOptions;
getOptions(): TinyflowOptions;
getData(): {
nodes: Node_2[];
edges: Edge[];
viewport: Viewport;
};
setData(data: TinyflowData): void;
destroy(): void;
}
export declare type TinyflowData = Partial<ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>>;
export declare type TinyflowOptions = {
element: string | Element;
data?: TinyflowData;
provider?: {
llm?: () => Item[] | Promise<Item[]>;
knowledge?: () => Item[] | Promise<Item[]>;
internal?: () => Item[] | Promise<Item[]>;
};
};
export { }

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,101 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/ai',
name: 'Ai',
meta: {
title: 'Ai',
hideInMenu: true,
},
children: [
{
path: 'knowledge/document',
component: () => import('#/views/ai/knowledge/document/index.vue'),
name: 'AiKnowledgeDocument',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '知识库文档',
activePath: '/ai/knowledge',
},
},
// {
// path: 'knowledge/document/create',
// component: () => import('#/views/ai/knowledge/document/form/index.vue'),
// name: 'AiKnowledgeDocumentCreate',
// meta: {
// noCache: true,
// hidden: true,
// canTo: true,
// title: '创建文档',
// activePath: '/ai/knowledge',
// },
// },
// {
// path: 'knowledge/document/update',
// component: () => import('#/views/ai/knowledge/document/form/index.vue'),
// name: 'AiKnowledgeDocumentUpdate',
// meta: {
// noCache: true,
// hidden: true,
// canTo: true,
// title: '修改文档',
// activePath: '/ai/knowledge',
// },
// },
{
path: 'knowledge/retrieval',
component: () =>
import('#/views/ai/knowledge/knowledge/retrieval/index.vue'),
name: 'AiKnowledgeRetrieval',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '文档召回测试',
activePath: '/ai/knowledge',
},
},
// {
// path: 'knowledge/segment',
// component: () => import('#/views/ai/knowledge/segment/index.vue'),
// name: 'AiKnowledgeSegment',
// meta: {
// noCache: true,
// hidden: true,
// canTo: true,
// title: '知识库分段',
// activePath: '/ai/knowledge',
// },
// },
{
path: 'console/workflow/create',
component: () => import('#/views/ai/workflow/form/index.vue'),
name: 'AiWorkflowCreate',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '设计 AI 工作流',
activePath: '/ai/workflow',
},
},
{
path: 'console/workflow/:type/:id',
component: () => import('#/views/ai/workflow/form/index.vue'),
name: 'AiWorkflowUpdate',
meta: {
noCache: true,
hidden: true,
canTo: true,
title: '设计 AI 工作流',
activePath: '/ai/workflow',
},
},
],
},
];
export default routes;

View File

@ -0,0 +1,157 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
import { getModelSimpleList } from '#/api/ai/model/model';
import {
AiModelTypeEnum,
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
} from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'name',
label: '知识库名称',
rules: 'required',
},
{
fieldName: 'description',
label: '知识库描述',
component: 'Textarea',
componentProps: {
row: 3,
placeholder: '请输入知识库描述',
},
},
{
component: 'ApiSelect',
fieldName: 'embeddingModelId',
label: '向量模型',
componentProps: {
api: () => getModelSimpleList(AiModelTypeEnum.EMBEDDING),
labelField: 'name',
valueField: 'id',
allowClear: true,
placeholder: '请选择向量模型',
},
rules: 'required',
},
{
fieldName: 'topK',
label: '检索 topK',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入检索 topK',
class: 'w-full',
min: 0,
max: 10,
},
rules: 'required',
},
{
fieldName: 'similarityThreshold',
label: '检索相似度阈值',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入检索相似度阈值',
class: 'w-full',
min: 0,
max: 1,
step: 0.01,
precision: 2,
},
rules: 'required',
},
{
fieldName: 'status',
label: '是否启用',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '文件名称',
component: 'Input',
},
{
fieldName: 'status',
label: '是否启用',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '文档编号',
},
{
field: 'name',
title: '文件名称',
},
{
field: 'contentLength',
title: '字符数',
},
{
field: 'tokens',
title: 'Token 数',
},
{
field: 'segmentMaxTokens',
title: '分片最大 Token 数',
},
{
field: 'retrievalCount',
title: '召回次数',
},
{
field: 'status',
title: '是否启用',
slots: { default: 'status' },
},
{
field: 'createTime',
title: '上传时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1 @@
<template><div></div></template>

View File

@ -0,0 +1,191 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiKnowledgeDocumentApi } from '#/api/ai/knowledge/document';
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@vben/access';
import { confirm, Page } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteKnowledgeDocument,
getKnowledgeDocumentPage,
updateKnowledgeDocumentStatus,
} from '#/api/ai/knowledge/document';
import { $t } from '#/locales';
import { CommonStatusEnum } from '#/utils/constants';
import { useGridColumns, useGridFormSchema } from './data';
/** AI 知识库文档 列表 */
defineOptions({ name: 'AiKnowledgeDocument' });
const { hasAccessByCodes } = useAccess();
const route = useRoute(); //
const router = useRouter(); //
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
router.push({
name: 'AiKnowledgeDocumentCreate',
query: { knowledgeId: route.query.knowledgeId },
});
}
/** 编辑 */
function handleEdit(id: number) {
router.push({
name: 'AiKnowledgeDocumentUpdate',
query: { id, knowledgeId: route.query.knowledgeId },
});
}
/** 删除 */
async function handleDelete(row: AiKnowledgeDocumentApi.KnowledgeDocumentVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteKnowledgeDocument(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 跳转到知识库分段页面 */
const handleSegment = (id: number) => {
router.push({
name: 'AiKnowledgeSegment',
query: { documentId: id },
});
};
/** 修改是否发布 */
const handleStatusChange = async (
row: AiKnowledgeDocumentApi.KnowledgeDocumentVO,
) => {
try {
//
const text = row.status ? '启用' : '禁用';
await confirm(`确认要"${text}"${row.name}文档吗?`).then(async () => {
await updateKnowledgeDocumentStatus({
id: row.id,
status: row.status,
});
onRefresh();
});
} catch {
row.status =
row.status === CommonStatusEnum.ENABLE
? CommonStatusEnum.DISABLE
: CommonStatusEnum.ENABLE;
}
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getKnowledgeDocumentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
knowledgeId: route.query.knowledgeId,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiKnowledgeDocumentApi.KnowledgeDocumentVO>,
});
/** 初始化 */
onMounted(() => {
// ID
if (!route.query.knowledgeId) {
message.error('知识库 ID 不存在,无法查看文档列表');
//
router.back();
}
});
</script>
<template>
<Page auto-content-height>
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['知识库文档']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:knowledge:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #status="{ row }">
<Switch
v-model:checked="row.publicStatus"
@change="handleStatusChange(row)"
:disabled="!hasAccessByCodes(['ai:knowledge:update'])"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:knowledge:update'],
onClick: handleEdit.bind(null, row.id),
},
]"
:drop-down-actions="[
{
label: '分段',
type: 'link',
auth: ['ai:knowledge:query'],
onClick: handleSegment.bind(null, row.id),
},
{
label: $t('common.delete'),
type: 'link',
auth: ['ai:api-key:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,162 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
import { getModelSimpleList } from '#/api/ai/model/model';
import {
AiModelTypeEnum,
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
} from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'name',
label: '知识库名称',
rules: 'required',
},
{
fieldName: 'description',
label: '知识库描述',
component: 'Textarea',
componentProps: {
row: 3,
placeholder: '请输入知识库描述',
},
},
{
component: 'ApiSelect',
fieldName: 'embeddingModelId',
label: '向量模型',
componentProps: {
api: () => getModelSimpleList(AiModelTypeEnum.EMBEDDING),
labelField: 'name',
valueField: 'id',
allowClear: true,
placeholder: '请选择向量模型',
},
rules: 'required',
},
{
fieldName: 'topK',
label: '检索 topK',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入检索 topK',
class: 'w-full',
min: 0,
max: 10,
},
rules: 'required',
},
{
fieldName: 'similarityThreshold',
label: '检索相似度阈值',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入检索相似度阈值',
class: 'w-full',
min: 0,
max: 1,
step: 0.01,
precision: 2,
},
rules: 'required',
},
{
fieldName: 'status',
label: '是否启用',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '知识库名称',
component: 'Input',
},
{
fieldName: 'status',
label: '是否启用',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
valueFormat: 'YYYY-MM-DD HH:mm:ss',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
},
{
field: 'name',
title: '知识库名称',
},
{
field: 'description',
title: '知识库描述',
},
{
field: 'embeddingModel',
title: '向量化模型',
},
{
field: 'status',
title: '是否启用',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -1,31 +1,162 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiKnowledgeKnowledgeApi } from '#/api/ai/knowledge/knowledge';
import { Button } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteKnowledge,
getKnowledgePage,
} from '#/api/ai/knowledge/knowledge';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑 */
function handleEdit(row: AiKnowledgeKnowledgeApi.KnowledgeVO) {
formModalApi.setData(row).open();
}
/** 删除 */
async function handleDelete(row: AiKnowledgeKnowledgeApi.KnowledgeVO) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteKnowledge(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
/** 文档按钮操作 */
const router = useRouter();
const handleDocument = (id: number) => {
router.push({
name: 'AiKnowledgeDocument',
query: { knowledgeId: id },
});
};
/** 跳转到文档召回测试页面 */
const handleRetrieval = (id: number) => {
router.push({
name: 'AiKnowledgeRetrieval',
query: { id },
});
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getKnowledgePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<AiKnowledgeKnowledgeApi.KnowledgeVO>,
});
</script>
<template>
<Page>
<DocAlert title="AI 知识库" url="https://doc.iocoder.cn/ai/knowledge/" />
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/knowledge/knowledge/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/knowledge/knowledge/index
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<DocAlert title="AI 手册" url="https://doc.iocoder.cn/ai/build/" />
<FormModal @success="onRefresh" />
<Grid table-title="AI ">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['AI 知识库']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:knowledge:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:knowledge:update'],
onClick: handleEdit.bind(null, row),
},
]"
:drop-down-actions="[
{
label: $t('ui.widgets.document'),
type: 'link',
auth: ['ai:knowledge:query'],
onClick: handleDocument.bind(null, row.id),
},
{
label: '召回测试',
type: 'link',
auth: ['ai:knowledge:query'],
onClick: handleRetrieval.bind(null, row.id),
},
{
label: $t('common.delete'),
type: 'link',
auth: ['ai:api-key:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { AiKnowledgeKnowledgeApi } from '#/api/ai/knowledge/knowledge';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createKnowledge,
getKnowledge,
updateKnowledge,
} from '#/api/ai/knowledge/knowledge';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<AiKnowledgeKnowledgeApi.KnowledgeVO>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['AI 知识库'])
: $t('ui.actionTitle.create', ['AI 知识库']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 140,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as AiKnowledgeKnowledgeApi.KnowledgeVO;
try {
await (formData.value?.id
? updateKnowledge(data)
: createKnowledge(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AiKnowledgeKnowledgeApi.KnowledgeVO>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getKnowledge(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,210 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Empty,
InputNumber,
message,
Textarea,
} from 'ant-design-vue';
import { getKnowledge } from '#/api/ai/knowledge/knowledge';
import { searchKnowledgeSegment } from '#/api/ai/knowledge/segment';
/** 文档召回测试 */
defineOptions({ name: 'KnowledgeDocumentRetrieval' });
const route = useRoute(); //
const router = useRouter(); //
const loading = ref(false); //
const segments = ref<any[]>([]); //
const queryParams = reactive({
id: undefined,
content: '',
topK: 10,
similarityThreshold: 0.5,
});
/** 调用文档召回测试接口 */
const getRetrievalResult = async () => {
if (!queryParams.content) {
message.warning('请输入查询文本');
return;
}
loading.value = true;
segments.value = [];
try {
const data = await searchKnowledgeSegment({
knowledgeId: queryParams.id,
content: queryParams.content,
topK: queryParams.topK,
similarityThreshold: queryParams.similarityThreshold,
});
segments.value = data || [];
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
/** 展开/收起段落内容 */
const toggleExpand = (segment: any) => {
segment.expanded = !segment.expanded;
};
/** 获取知识库信息 */
const getKnowledgeInfo = async (id: number) => {
try {
const knowledge = await getKnowledge(id);
if (knowledge) {
queryParams.topK = knowledge.topK || queryParams.topK;
queryParams.similarityThreshold =
knowledge.similarityThreshold || queryParams.similarityThreshold;
}
} catch {}
};
/** 初始化 */
onMounted(() => {
// ID
if (!route.query.id) {
message.error('知识库 ID 不存在,无法进行召回测试');
router.back();
return;
}
queryParams.id = route.query.id as any;
//
getKnowledgeInfo(queryParams.id as any);
});
</script>
<template>
<Page auto-content-height>
<div class="flex w-full gap-4">
<Card class="min-w-300 flex-1">
<div class="mb-15">
<h3
class="m-0 mb-2 font-semibold leading-none tracking-tight"
style="font-size: 18px"
>
召回测试
</h3>
<div class="text-14 text-gray-500">
根据给定的查询文本测试召回效果
</div>
</div>
<div>
<div class="relative mb-2">
<Textarea
v-model:value="queryParams.content"
:rows="8"
placeholder="请输入文本"
/>
<div class="text-12 absolute bottom-2 right-2 text-gray-400">
{{ queryParams.content?.length }} / 200
</div>
</div>
<div class="mb-2 flex items-center">
<span class="w-16 text-gray-500">topK:</span>
<InputNumber
v-model:value="queryParams.topK"
:min="1"
:max="20"
class="w-full"
/>
</div>
<div class="mb-2 flex items-center">
<span class="w-16 text-gray-500">相似度:</span>
<InputNumber
v-model:value="queryParams.similarityThreshold"
class="w-full"
:min="0"
:max="1"
:precision="2"
:step="0.01"
/>
</div>
<div class="flex justify-end">
<Button
type="primary"
@click="getRetrievalResult"
:loading="loading"
>
测试
</Button>
</div>
</div>
</Card>
<Card class="min-w-300 flex-1">
<!-- 加载中状态 -->
<template v-if="loading">
<div class="flex h-[300px] items-center justify-center">
<Empty description="正在检索中..." />
</div>
</template>
<!-- 有段落 -->
<template v-else-if="segments.length > 0">
<div class="mb-15 font-bold">{{ segments.length }} 个召回段落</div>
<div>
<div
v-for="(segment, index) in segments"
:key="index"
class="p-15 mb-20 rounded border border-solid border-gray-200"
>
<div class="text-12 mb-5 flex justify-between text-gray-500">
<span>
分段({{ segment.id }}) · {{ segment.contentLength }} 字符数 ·
{{ segment.tokens }} Token
</span>
<span
class="-8 text-12 rounded-full bg-blue-50 py-4 font-bold text-blue-500"
>
score: {{ segment.score }}
</span>
</div>
<div
class="text-13 mb-10 overflow-hidden whitespace-pre-wrap rounded bg-gray-50 p-10 transition-all duration-100"
:class="{
'max-h-50 line-clamp-2': !segment.expanded,
'max-h-500': segment.expanded,
}"
>
{{ segment.content }}
</div>
<div class="flex items-center justify-between">
<div class="text-13 flex items-center text-gray-500">
<span class="ep:document mr-5"></span>
<span>{{ segment.documentName || '未知文档' }}</span>
</div>
<Button size="small" @click="toggleExpand(segment)">
{{ segment.expanded ? '收起' : '展开' }}
<span
class="mr-5"
:class="segment.expanded ? 'ep:arrow-up' : 'ep:arrow-down'"
></span>
</Button>
</div>
</div>
</div>
</template>
<!-- 无召回结果 -->
<template v-else>
<div class="flex h-[300px] items-center justify-center">
<Empty description="暂无召回结果" />
</div>
</template>
</Card>
</div>
</Page>
</template>

View File

@ -37,9 +37,11 @@ onMounted(() => {
if (contentAreaHeight.value && !initialized) {
/** 初始化思维导图 */
try {
markMap = Markmap.create(svgRef.value!);
const { el } = Toolbar.create(markMap);
toolBarRef.value?.append(el);
if (!markMap) {
markMap = Markmap.create(svgRef.value!);
const { el } = Toolbar.create(markMap);
toolBarRef.value?.append(el);
}
nextTick(update);
} catch {
message.error('思维导图初始化失败');

View File

@ -3,9 +3,9 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiMindmapApi } from '#/api/ai/mindmap';
import type { SystemUserApi } from '#/api/system/user';
import { onMounted, ref } from 'vue';
import { nextTick, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { message } from 'ant-design-vue';
@ -15,9 +15,17 @@ import { getSimpleUserList } from '#/api/system/user';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import Right from '../index/modules/Right.vue';
import { useGridColumns, useGridFormSchema } from './data';
const userList = ref<SystemUserApi.User[]>([]); //
const previewVisible = ref(false); // drawer
const previewContent = ref('');
const [Drawer, drawerApi] = useVbenDrawer({
header: false,
footer: false,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
@ -68,6 +76,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
} as VxeTableGridOptions<AiMindmapApi.MindMapVO>,
});
const openPreview = async (row: AiMindmapApi.MindMapVO) => {
previewVisible.value = false;
drawerApi.open();
await nextTick();
previewVisible.value = true;
previewContent.value = row.generatedContent;
};
onMounted(async () => {
//
userList.value = await getSimpleUserList();
@ -77,6 +92,15 @@ onMounted(async () => {
<template>
<Page auto-content-height>
<DocAlert title="AI 思维导图" url="https://doc.iocoder.cn/ai/mindmap/" />
<Drawer class="w-[800px]">
<Right
v-if="previewVisible"
:generated-content="previewContent"
:is-end="true"
:is-generating="false"
:is-start="false"
/>
</Drawer>
<Grid table-title="">
<template #toolbar-tools>
<TableAction :actions="[]" />
@ -89,6 +113,13 @@ onMounted(async () => {
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('ui.cropper.preview'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:api-key:update'],
onClick: openPreview.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',

View File

@ -0,0 +1,80 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'code',
label: '流程标识',
component: 'Input',
},
{
fieldName: 'name',
label: '流程名称',
component: 'Input',
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
valueFormat: 'YYYY-MM-DD HH:mm:ss',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
},
{
field: 'code',
title: '流程标识',
},
{
field: 'name',
title: '流程名称',
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
field: 'remark',
title: '备注',
},
{
field: 'status',
title: '状态',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,289 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, provide, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { confirm, Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { ArrowLeft } from '@vben/icons';
import { Button, Card, message } from 'ant-design-vue';
import { getModelSimpleList } from '#/api/ai/model/model';
import { createWorkflow, getWorkflow, updateWorkflow } from '#/api/ai/workflow';
import { createModel, deployModel, updateModel } from '#/api/bpm/model';
import { AiModelTypeEnum, CommonStatusEnum } from '#/utils';
import BasicInfo from './modules/basic-info.vue';
import WorkflowDesign from './modules/workflow-design.vue';
defineOptions({ name: 'AiWorkflowCreate' });
const router = useRouter();
const route = useRoute();
//
const basicInfoRef = ref<InstanceType<typeof BasicInfo>>();
//
const workflowDesignRef = ref<InstanceType<typeof WorkflowDesign>>();
/** 步骤校验函数 */
const validateBasic = async () => {
await basicInfoRef.value?.validate();
};
/** 工作流设计校验 */
const validateWorkflow = async () => {
await workflowDesignRef.value?.validate();
};
const currentStep = ref(-1); // -1
const steps = [
{ title: '基本信息', validator: validateBasic },
{ title: '工作流设计', validator: validateWorkflow },
];
//
const formData: any = ref({
id: undefined,
name: '',
code: '',
remark: '',
graph: '',
status: CommonStatusEnum.ENABLE,
});
const llmProvider = ref<any>([]);
const workflowData = ref<any>({});
provide('workflowData', workflowData);
/** 初始化数据 */
const actionType = route.params.type as string;
const initData = async () => {
if (actionType === 'update') {
const workflowId = route.params.id as string;
formData.value = await getWorkflow(workflowId);
workflowData.value = JSON.parse(formData.value.graph);
}
const models = await getModelSimpleList(AiModelTypeEnum.CHAT);
llmProvider.value = {
llm: () =>
models.map(({ id, name }) => ({
value: id,
label: name,
})),
knowledge: () => [],
internal: () => [],
};
//
currentStep.value = 0;
};
/** 校验所有步骤数据是否完整 */
const validateAllSteps = async () => {
//
try {
await validateBasic();
} catch {
currentStep.value = 0;
throw new Error('请完善基本信息');
}
//
try {
await validateWorkflow();
} catch {
currentStep.value = 1;
throw new Error('请完善工作流信息');
}
return true;
};
/** 保存操作 */
const handleSave = async () => {
try {
//
await validateAllSteps();
//
const data = {
...formData.value,
graph: JSON.stringify(workflowData.value),
};
await (actionType === 'update'
? updateWorkflow(data)
: createWorkflow(data));
//
message.success('保存成功');
tabs.closeCurrentTab();
await router.push({ name: 'AiWorkflow' });
} catch (error: any) {
console.error('保存失败:', error);
message.warning(error.message || '请完善所有步骤的必填信息');
}
};
/** 发布操作 */
const handleDeploy = async () => {
try {
//
if (!formData.value.id) {
await confirm('是否确认发布该流程?');
}
//
await validateAllSteps();
//
const modelData = {
...formData.value,
};
//
if (formData.value.id) {
await updateModel(modelData);
} else {
const result = await createModel(modelData);
formData.value.id = result.id;
}
//
await deployModel(formData.value.id);
message.success('发布成功');
// TODO
await router.push({ name: '/ai/workflow' });
} catch (error: any) {
console.error('发布失败:', error);
message.warning(error.message || '发布失败');
}
};
/** 步骤切换处理 */
const handleStepClick = async (index: number) => {
try {
if (index !== 0) {
await validateBasic();
}
if (index !== 1) {
await validateWorkflow();
}
//
currentStep.value = index;
} catch (error) {
console.error('步骤切换失败:', error);
message.warning('请先完善当前步骤必填信息');
}
};
const tabs = useTabs();
/** 返回列表页 */
const handleBack = () => {
//
tabs.closeCurrentTab();
// 使 name 'name'+ menuId
router.push({ path: '/ai/workflow' });
};
/** 初始化 */
onMounted(async () => {
await initData();
});
/** 添加组件卸载前的清理 */
onBeforeUnmount(() => {
//
basicInfoRef.value = undefined;
workflowDesignRef.value = undefined;
});
</script>
<template>
<Page auto-content-height>
<div class="mx-auto">
<!-- 头部导航栏 -->
<div
class="absolute inset-x-0 top-0 z-10 flex h-12 items-center border-b bg-white px-5"
>
<!-- 左侧标题 -->
<div class="flex w-[200px] items-center overflow-hidden">
<ArrowLeft
class="size-5 flex-shrink-0 cursor-pointer"
@click="handleBack"
/>
<span
class="ml-2.5 truncate text-base"
:title="formData.name || '创建AI 工作流'"
>
{{ formData.name || '创建AI 工作流' }}
</span>
</div>
<!-- 步骤条 -->
<div class="flex h-full flex-1 items-center justify-center">
<div class="flex h-full w-[400px] items-center justify-between">
<div
v-for="(step, index) in steps"
:key="index"
class="relative mx-[15px] flex h-full cursor-pointer items-center"
:class="[
currentStep === index
? 'border-b-2 border-solid border-blue-500 text-blue-500'
: 'text-gray-500',
]"
@click="handleStepClick(index)"
>
<div
class="mr-2 flex h-7 w-7 items-center justify-center rounded-full border-2 border-solid text-[15px]"
:class="[
currentStep === index
? 'border-blue-500 bg-blue-500 text-white'
: 'border-gray-300 bg-white text-gray-500',
]"
>
{{ index + 1 }}
</div>
<span class="whitespace-nowrap text-base font-bold">{{
step.title
}}</span>
</div>
</div>
</div>
<!-- 右侧按钮 -->
<div class="flex w-[200px] items-center justify-end gap-2">
<Button
v-if="actionType === 'update'"
type="primary"
@click="handleDeploy"
>
</Button>
<Button type="primary" @click="handleSave">
<span v-if="actionType === 'definition'"> </span>
<span v-else> </span>
</Button>
</div>
</div>
<!-- 主体内容 -->
<Card :body-style="{ padding: '10px' }" class="mb-4">
<div class="mt-[50px]">
<!-- 第一步基本信息 -->
<div v-if="currentStep === 0" class="mx-auto w-4/6">
<BasicInfo v-model="formData" ref="basicInfoRef" />
</div>
<!-- 第二步表单设计 -->
<WorkflowDesign
v-if="currentStep === 1"
v-model="formData"
:provider="llmProvider"
ref="workflowDesignRef"
/>
</div>
</Card>
</div>
</Page>
</template>

View File

@ -0,0 +1,72 @@
<script lang="ts" setup>
import type { Rule } from 'ant-design-vue/es/form';
import { ref } from 'vue';
import { Form, Input, Select } from 'ant-design-vue';
import { DICT_TYPE, getIntDictOptions } from '#/utils';
//
const modelData = defineModel<any>();
//
const formRef = ref();
const rules: Record<string, Rule[]> = {
code: [{ required: true, message: '流程标识不能为空', trigger: 'blur' }],
name: [{ required: true, message: '流程名称不能为空', trigger: 'blur' }],
status: [{ required: true, message: '状态不能为空', trigger: 'change' }],
};
/** 表单校验 */
const validate = async () => {
await formRef.value?.validate();
};
defineExpose({ validate });
</script>
<template>
<Form
ref="formRef"
:model="modelData"
:rules="rules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
class="mt-5"
>
<Form.Item label="流程标识" name="code" class="mb-5">
<Input
class="w-full"
v-model:value="modelData.code"
allow-clear
placeholder="请输入流程标识"
/>
</Form.Item>
<Form.Item label="流程名称" name="name" class="mb-5">
<Input
v-model:value="modelData.name"
allow-clear
placeholder="请输入流程名称"
/>
</Form.Item>
<Form.Item label="状态" name="status" class="mb-5">
<Select
class="w-full"
v-model:value="modelData.status"
allow-clear
placeholder="请选择状态"
>
<Select.Option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="流程描述" name="description" class="mb-5">
<Input.TextArea v-model:value="modelData.description" allow-clear />
</Form.Item>
</Form>
</template>

View File

@ -0,0 +1,286 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import { inject, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Button, Input, Select } from 'ant-design-vue';
import { testWorkflow } from '#/api/ai/workflow';
import Tinyflow from '#/components/Tinyflow/Tinyflow.vue';
defineProps<{
provider: any;
}>();
const tinyflowRef = ref();
const workflowData = inject('workflowData') as Ref;
const params4Test = ref<any[]>([]);
const paramsOfStartNode = ref<any>({});
const testResult = ref(null);
const loading = ref(false);
const error = ref(null);
const [Drawer, drawerApi] = useVbenDrawer({
footer: false,
closeOnClickModal: false,
modal: false
});
/** 展示工作流测试抽屉 */
const testWorkflowModel = () => {
drawerApi.open();
const startNode = getStartNode();
//
const parameters = startNode.data?.parameters || [];
const paramDefinitions = {};
// 便
parameters.forEach((param) => {
paramDefinitions[param.name] = param;
});
function mergeIfRequiredButNotSet(target) {
const needPushList = [];
for (const key in paramDefinitions) {
const param = paramDefinitions[key];
if (param.required) {
const item = target.find((item) => item.key === key);
if (!item) {
needPushList.push({
key: param.name,
value: param.defaultValue || '',
});
}
}
}
target.push(...needPushList);
}
//
mergeIfRequiredButNotSet(params4Test.value);
paramsOfStartNode.value = paramDefinitions;
};
/** 运行流程 */
const goRun = async () => {
try {
const val = tinyflowRef.value.getData();
loading.value = true;
error.value = null;
testResult.value = null;
// / start
const startNode = getStartNode();
//
const parameters = startNode.data?.parameters || [];
const paramDefinitions = {};
parameters.forEach((param) => {
paramDefinitions[param.name] = param.dataType;
});
//
const convertedParams = {};
for (const { key, value } of params4Test.value) {
const paramKey = key.trim();
if (!paramKey) continue;
let dataType = paramDefinitions[paramKey];
if (!dataType) {
dataType = 'String';
}
try {
convertedParams[paramKey] = convertParamValue(value, dataType);
} catch (error_) {
throw new Error(`参数 ${paramKey} 转换失败: ${error_.message}`);
}
}
const data = {
graph: JSON.stringify(val),
params: convertedParams,
};
const response = await testWorkflow(data);
testResult.value = response;
} catch (error_) {
error.value =
error_.response?.data?.message || '运行失败,请检查参数和网络连接';
} finally {
loading.value = false;
}
};
/** 获取开始节点 */
const getStartNode = () => {
const val = tinyflowRef.value.getData();
const startNode = val.nodes.find((node) => node.type === 'startNode');
if (!startNode) {
throw new Error('流程缺少开始节点');
}
return startNode;
};
/** 添加参数项 */
const addParam = () => {
params4Test.value.push({ key: '', value: '' });
};
/** 删除参数项 */
const removeParam = (index) => {
params4Test.value.splice(index, 1);
};
/** 类型转换函数 */
const convertParamValue = (value, dataType) => {
if (value === '') return null; //
switch (dataType) {
case 'Number': {
const num = Number(value);
if (isNaN(num)) throw new Error('非数字格式');
return num;
}
case 'String': {
return String(value);
}
case 'Boolean': {
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
throw new Error('必须为 true/false');
}
case 'Array':
case 'Object': {
try {
return JSON.parse(value);
} catch (error_) {
throw new Error(`JSON格式错误: ${error_.message}`);
}
}
default: {
throw new Error(`不支持的类型: ${dataType}`);
}
}
};
/** 表单校验 */
const validate = async () => {
//
if (!workflowData.value) {
throw new Error('请设计流程');
}
workflowData.value = tinyflowRef.value.getData();
return true;
};
defineExpose({ validate });
</script>
<template>
<div class="relative" style="width: 100%; height: 700px">
<Tinyflow
v-if="workflowData"
ref="tinyflowRef"
class-name="custom-class"
:style="{ width: '100%', height: '100%' }"
:data="workflowData"
:provider="provider"
/>
<div class="absolute right-[30px] top-[30px]">
<Button
@click="testWorkflowModel"
type="primary"
v-hasPermi="['ai:workflow:test']"
>
测试
</Button>
</div>
<Drawer title="工作流测试">
<fieldset>
<legend class="ml-2"><h3>运行参数配置</h3></legend>
<div class="p-2">
<div
class="mb-1 flex justify-around"
v-for="(param, index) in params4Test"
:key="index"
>
<Select class="w-[200px]" v-model="param.key" placeholder="参数名">
<Select.Option
v-for="(value, key) in paramsOfStartNode"
:key="key"
:value="key"
:disabled="!!value?.disabled"
>
{{ value?.description || key }}
</Select.Option>
</Select>
<Input
class="w-[200px]"
v-model:value="param.value"
placeholder="参数值"
/>
<Button danger plain circle @click="removeParam(index)">
<template #icon>
<span class="icon-[ant-design--delete-outlined]"></span>
</template>
</Button>
</div>
<Button type="primary" plain @click="addParam"></Button>
</div>
</fieldset>
<fieldset class="mt-2" style="background-color: #f8f9fa">
<legend class="ml-2"><h3>运行结果</h3></legend>
<div class="p-2">
<div v-if="loading"><el-text type="primary">执行中...</el-text></div>
<div v-else-if="error">
<el-text type="danger">{{ error }}</el-text>
</div>
<pre v-else-if="testResult" class="result-content">
{{ JSON.stringify(testResult, null, 2) }}
</pre>
<div v-else style="color: #909399">点击运行查看结果</div>
</div>
</fieldset>
<Button
class="mt-2"
size="large"
style="width: 100%; color: white; background-color: #67c23a"
@click="goRun"
>
运行流程
</Button>
</Drawer>
</div>
</template>
<style lang="css" scoped>
.result-content {
max-height: 300px;
padding: 12px;
overflow: auto;
font-family: Monaco, Consolas, monospace;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
background: white;
border-radius: 4px;
}
fieldset {
min-inline-size: auto;
padding: 12px 16px;
margin: 0;
border: 1px solid #dcdfe6;
border-radius: 6px;
}
legend {
padding: 0 10px;
font-size: 16px;
font-weight: 600;
color: #303133;
}
</style>

View File

@ -1,28 +1,128 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteWorkflow, getWorkflowPage } from '#/api/ai/workflow';
import { $t } from '#/locales';
import { router } from '#/router';
import { useGridColumns, useGridFormSchema } from './data';
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建 */
function handleCreate() {
router.push({
name: 'AiWorkflowCreate',
});
}
/** 编辑 */
function handleEdit(row: any) {
router.push({
name: 'AiWorkflowCreate',
query: {
id: row.id,
type: 'update',
},
});
}
/** 删除 */
async function handleDelete(row: any) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
key: 'action_key_msg',
});
try {
await deleteWorkflow(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_key_msg',
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getWorkflowPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<any>,
});
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/workflow/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/workflow/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Grid table-title="AI ">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['AI 工作流']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['ai:workflow:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['ai:workflow:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['ai:workflow:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,241 @@
<script setup lang="ts">
import type { AiWriteApi } from '#/api/ai/write';
import { ref } from 'vue';
import { createReusableTemplate } from '@vueuse/core';
import { Button, message, Textarea } from 'ant-design-vue';
import { DICT_TYPE, getIntDictOptions } from '#/utils';
import { AiWriteTypeEnum, WriteExample } from '#/utils/constants';
import Tag from './Tag.vue';
type TabType = AiWriteApi.WriteVO['type'];
defineProps<{
isWriting: boolean;
}>();
const emit = defineEmits<{
(e: 'example', param: 'reply' | 'write'): void;
(e: 'reset'): void;
(e: 'submit', params: Partial<AiWriteApi.WriteVO>): void;
}>();
function omit(obj: Record<string, any>, keysToOmit: string[]) {
const result: Record<string, any> = {};
for (const key in obj) {
if (!keysToOmit.includes(key)) {
result[key] = obj[key];
}
}
return result;
}
/** 点击示例的时候,将定义好的文章作为示例展示出来 */
const example = (type: 'reply' | 'write') => {
formData.value = {
...initData,
...omit(WriteExample[type], ['data']),
};
emit('example', type);
};
/** 重置,将表单值作为初选值 */
const reset = () => {
formData.value = { ...initData };
emit('reset');
};
const selectedTab = ref<TabType>(AiWriteTypeEnum.WRITING);
const tabs: {
text: string;
value: TabType;
}[] = [
{ text: '撰写', value: AiWriteTypeEnum.WRITING },
{ text: '回复', value: AiWriteTypeEnum.REPLY },
];
const [DefineTab, ReuseTab] = createReusableTemplate<{
active?: boolean;
itemClick: () => void;
text: string;
}>();
const [DefineLabel, ReuseLabel] = createReusableTemplate<{
class?: string;
hint?: string;
hintClick?: () => void;
label: string;
}>();
const initData: AiWriteApi.WriteVO = {
type: 1,
prompt: '',
originalContent: '',
tone: 1,
language: 1,
length: 1,
format: 1,
};
const formData = ref<AiWriteApi.WriteVO>({ ...initData });
/** 用来记录切换之前所填写的数据,切换的时候给赋值回来 */
const recordFormData = {} as Record<AiWriteTypeEnum, AiWriteApi.WriteVO>;
/** 切换tab */
const switchTab = (value: TabType) => {
if (value !== selectedTab.value) {
//
recordFormData[selectedTab.value] = formData.value;
selectedTab.value = value;
//
formData.value = { ...initData, ...recordFormData[value] };
}
};
/** 提交写作 */
const submit = () => {
if (selectedTab.value === 2 && !formData.value.originalContent) {
message.warning('请输入原文');
return;
}
if (!formData.value.prompt) {
message.warning(`请输入${selectedTab.value === 1 ? '写作' : '回复'}内容`);
return;
}
emit('submit', {
/** 撰写的时候没有 originalContent 字段*/
...(selectedTab.value === 1
? omit(formData.value, ['originalContent'])
: formData.value),
/** 使用选中 tab 值覆盖当前的 type 类型 */
type: selectedTab.value,
});
};
</script>
<template>
<DefineTab v-slot="{ active, text, itemClick }">
<span
:class="active ? 'text-black shadow-md' : 'hover:bg-[#DDDFE3]'"
class="z-1 relative inline-block w-1/2 cursor-pointer rounded-full text-center leading-[30px] text-[5C6370] hover:text-black"
@click="itemClick"
>
{{ text }}
</span>
</DefineTab>
<!-- 定义 label 组件长度/格式/语气/语言等 -->
<DefineLabel v-slot="{ label, hint, hintClick }">
<h3 class="mb-3 mt-5 flex items-center justify-between text-[14px]">
<span>{{ label }}</span>
<span
v-if="hint"
class="flex cursor-pointer select-none items-center text-[12px] text-[#846af7]"
@click="hintClick"
>
<span class="icon-[ant-design--question-circle-outlined]"> </span>
{{ hint }}
</span>
</h3>
</DefineLabel>
<div class="flex flex-col" v-bind="$attrs">
<div class="flex w-full justify-center bg-[#f5f7f9] pt-2">
<div class="z-10 w-[303px] rounded-full bg-[#DDDFE3] p-1">
<div
:class="
selectedTab === AiWriteTypeEnum.REPLY &&
'after:translate-x-[100%] after:transform'
"
class="relative flex items-center after:absolute after:left-0 after:top-0 after:block after:h-[30px] after:w-1/2 after:rounded-full after:bg-white after:transition-transform after:content-['']"
>
<ReuseTab
v-for="tab in tabs"
:key="tab.value"
:active="tab.value === selectedTab"
:item-click="() => switchTab(tab.value)"
:text="tab.text"
class="relative z-20"
/>
</div>
</div>
</div>
<div
class="box-border h-full w-[380px] flex-grow overflow-y-auto bg-[#f5f7f9] px-7 pb-2 lg:block"
>
<div>
<template v-if="selectedTab === 1">
<ReuseLabel
:hint-click="() => example('write')"
hint="示例"
label="写作内容"
/>
<Textarea
v-model:value="formData.prompt"
:maxlength="500"
:rows="5"
placeholder="请输入写作内容"
show-count
/>
</template>
<template v-else>
<ReuseLabel
:hint-click="() => example('reply')"
hint="示例"
label="原文"
/>
<Textarea
v-model:value="formData.originalContent"
:maxlength="500"
:rows="5"
placeholder="请输入原文"
show-count
/>
<ReuseLabel label="回复内容" />
<Textarea
v-model:value="formData.prompt"
:maxlength="500"
:rows="5"
placeholder="请输入回复内容"
show-count
/>
</template>
<ReuseLabel label="长度" />
<Tag
v-model="formData.length"
:tags="getIntDictOptions(DICT_TYPE.AI_WRITE_LENGTH)"
/>
<ReuseLabel label="格式" />
<Tag
v-model="formData.format"
:tags="getIntDictOptions(DICT_TYPE.AI_WRITE_FORMAT)"
/>
<ReuseLabel label="语气" />
<Tag
v-model="formData.tone"
:tags="getIntDictOptions(DICT_TYPE.AI_WRITE_TONE)"
/>
<ReuseLabel label="语言" />
<Tag
v-model="formData.language"
:tags="getIntDictOptions(DICT_TYPE.AI_WRITE_LANGUAGE)"
/>
<div class="mt-3 flex items-center justify-center">
<Button :disabled="isWriting" class="mr-2" @click="reset">
重置
</Button>
<Button
:loading="isWriting"
style="color: white; background-color: #846af7"
@click="submit"
>
生成
</Button>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useClipboard } from '@vueuse/core';
import { Button, Card, message, Textarea } from 'ant-design-vue';
//
const props = defineProps({
content: {
//
type: String,
default: '',
},
isWriting: {
//
type: Boolean,
default: false,
},
});
const emits = defineEmits(['update:content', 'stopStream']);
const { copied, copy } = useClipboard();
/** 通过计算属性,双向绑定,更改生成的内容,考虑到用户想要更改生成文章的情况 */
const compContent = computed({
get() {
return props.content;
},
set(val) {
emits('update:content', val);
},
});
/** 滚动 */
const contentRef = ref<HTMLDivElement>();
defineExpose({
scrollToBottom() {
contentRef.value?.scrollTo(0, contentRef.value?.scrollHeight);
},
});
/** 点击复制的时候复制内容 */
const showCopy = computed(() => props.content && !props.isWriting); //
const copyContent = () => {
copy(props.content);
};
/** 复制成功的时候 copied.value 为 true */
watch(copied, (val) => {
if (val) {
message.success('复制成功');
}
});
</script>
<template>
<Card class="my-card h-full">
<template #title>
<h3 class="m-0 flex shrink-0 items-center justify-between px-7">
<span>预览</span>
<!-- 展示在右上角 -->
<Button
style="color: white; background-color: #846af7"
v-show="showCopy"
@click="copyContent"
size="small"
class="flex"
>
<template #icon>
<div class="flex items-center justify-center">
<span class="icon-[ant-design--copy-twotone]"></span>
</div>
</template>
复制
</Button>
</h3>
</template>
<div
ref="contentRef"
class="hide-scroll-bar box-border h-full overflow-y-auto"
>
<div
class="relative box-border min-h-full w-full flex-grow bg-white p-3 sm:p-7"
>
<!-- 终止生成内容的按钮 -->
<Button
v-show="isWriting"
class="z-36 absolute bottom-2 left-1/2 flex -translate-x-1/2 sm:bottom-5"
@click="emits('stopStream')"
size="small"
>
<template #icon>
<div class="flex items-center justify-center">
<span class="icon-[ant-design--stop-twotone]"></span>
</div>
</template>
终止生成
</Button>
<Textarea
id="inputId"
v-model:value="compContent"
autosize
:bordered="false"
placeholder="生成的内容……"
/>
</div>
</div>
</Card>
</template>
<style lang="scss" scoped>
// mixin extend
@mixin hide-scroll-bar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
.hide-scroll-bar {
@include hide-scroll-bar;
}
.my-card {
display: flex;
flex-direction: column;
:deep(.ant-card-body) {
box-sizing: border-box;
flex-grow: 1;
padding: 0;
overflow-y: auto;
@include hide-scroll-bar;
}
}
// markmaptool
:deep(.markmap) {
width: 100%;
}
:deep(.mm-toolbar-brand) {
display: none;
}
:deep(.mm-toolbar) {
display: flex;
flex-direction: row;
}
</style>

View File

@ -0,0 +1,32 @@
<!-- 标签选项 -->
<script setup lang="ts">
const props = withDefaults(
defineProps<{
[k: string]: any;
modelValue: string;
tags: { label: string; value: string }[];
}>(),
{
tags: () => [],
},
);
const emits = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
</script>
<template>
<div class="flex flex-wrap gap-[8px]">
<span
v-for="tag in props.tags"
:key="tag.value"
class="tag mb-2 cursor-pointer rounded-[4px] border-[2px] border-solid border-[#DDDFE3] bg-[#DDDFE3] px-2 text-[12px] leading-6"
:class="modelValue === tag.value && '!border-[#846af7] text-[#846af7]'"
@click="emits('update:modelValue', tag.value)"
>
{{ tag.label }}
</span>
</div>
</template>
<style scoped></style>

View File

@ -1,28 +1,86 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import type { AiWriteApi } from '#/api/ai/write';
import { Button } from 'ant-design-vue';
import { nextTick, ref } from 'vue';
import { alert, Page } from '@vben/common-ui';
import { writeStream } from '#/api/ai/write';
import { WriteExample } from '#/utils/constants';
import Left from './components/Left.vue';
import Right from './components/Right.vue';
const writeResult = ref(''); //
const isWriting = ref(false); //
const abortController = ref<AbortController>(); // // abort ( stream )
/** 停止 stream 生成 */
const stopStream = () => {
abortController.value?.abort();
isWriting.value = false;
};
/** 执行写作 */
const rightRef = ref<InstanceType<typeof Right>>();
const submit = (data: Partial<AiWriteApi.WriteVO>) => {
abortController.value = new AbortController();
writeResult.value = '';
isWriting.value = true;
writeStream({
data,
onMessage: async (res: any) => {
const { code, data, msg } = JSON.parse(res.data);
if (code !== 0) {
alert(`写作异常! ${msg}`);
stopStream();
return;
}
writeResult.value = writeResult.value + data;
//
await nextTick();
rightRef.value?.scrollToBottom();
},
ctrl: abortController.value,
onClose: stopStream,
onError: (error: any) => {
console.error('写作异常', error);
stopStream();
//
throw error;
},
});
};
/** 点击示例触发 */
const handleExampleClick = (type: keyof typeof WriteExample) => {
writeResult.value = WriteExample[type].data;
};
/** 点击重置的时候清空写作的结果*/
const reset = () => {
writeResult.value = '';
};
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/write/index/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/write/index/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<div class="absolute bottom-0 left-0 right-0 top-0 flex">
<Left
:is-writing="isWriting"
class="h-full"
@submit="submit"
@reset="reset"
@example="handleExampleClick"
/>
<Right
:is-writing="isWriting"
@stop-stream="stopStream"
ref="rightRef"
class="flex-grow"
v-model:content="writeResult"
/>
</div>
</Page>
</template>

View File

@ -10,7 +10,7 @@ export default defineConfig(async () => {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/admin-api/, ''),
// mock代理目标地址
target: 'http://localhost:48080/admin-api',
target: 'http://192.168.2.107:48080/admin-api',
ws: true,
},
},