feat(ai): 添加 AI 写作、知识库、思维导图和工作流功能
- 新增 AI 写作功能,包括示例点击、重置和停止流等功能 - 实现 AI 知识库管理,支持创建、编辑和删除知识库 - 添加 AI 思维导图功能,支持预览和管理思维导图 - 实现 AI 工作流管理,支持创建、编辑和删除工作流 - 优化 API 调用,使用 Vben 组件库和 Vue 3 相关特性pull/145/head
parent
54066859c5
commit
a4e44379e8
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -50,13 +50,19 @@ export namespace AiWriteApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeStream(
|
export function writeStream({
|
||||||
data: any,
|
data,
|
||||||
onClose: any,
|
onClose,
|
||||||
onMessage: any,
|
onMessage,
|
||||||
onError: any,
|
onError,
|
||||||
ctrl: any,
|
ctrl,
|
||||||
) {
|
}: {
|
||||||
|
ctrl: AbortController;
|
||||||
|
data: Partial<AiWriteApi.WriteVO>;
|
||||||
|
onClose?: (...args: any[]) => void;
|
||||||
|
onError?: (...args: any[]) => void;
|
||||||
|
onMessage?: (res: any) => void;
|
||||||
|
}) {
|
||||||
const token = accessStore.accessToken;
|
const token = accessStore.accessToken;
|
||||||
return fetchEventSource(
|
return fetchEventSource(
|
||||||
`${import.meta.env.VITE_BASE_URL}/ai/write/generate-stream`,
|
`${import.meta.env.VITE_BASE_URL}/ai/write/generate-stream`,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||||
|
|
@ -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' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<template><div></div></template>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,162 @@
|
||||||
<script lang="ts" setup>
|
<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 { 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page>
|
<Page auto-content-height>
|
||||||
<DocAlert title="AI 知识库" url="https://doc.iocoder.cn/ai/knowledge/" />
|
<DocAlert title="AI 手册" url="https://doc.iocoder.cn/ai/build/" />
|
||||||
<Button
|
<FormModal @success="onRefresh" />
|
||||||
danger
|
<Grid table-title="AI 知识库列表">
|
||||||
type="link"
|
<template #toolbar-tools>
|
||||||
target="_blank"
|
<TableAction
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
:actions="[
|
||||||
>
|
{
|
||||||
该功能支持 Vue3 + element-plus 版本!
|
label: $t('ui.actionTitle.create', ['AI 知识库']),
|
||||||
</Button>
|
type: 'primary',
|
||||||
<br />
|
icon: ACTION_ICON.ADD,
|
||||||
<Button
|
auth: ['ai:knowledge:create'],
|
||||||
type="link"
|
onClick: handleCreate,
|
||||||
target="_blank"
|
},
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/knowledge/knowledge/index"
|
]"
|
||||||
>
|
/>
|
||||||
可参考
|
</template>
|
||||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/knowledge/knowledge/index
|
<template #actions="{ row }">
|
||||||
代码,pull request 贡献给我们!
|
<TableAction
|
||||||
</Button>
|
: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>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -37,9 +37,11 @@ onMounted(() => {
|
||||||
if (contentAreaHeight.value && !initialized) {
|
if (contentAreaHeight.value && !initialized) {
|
||||||
/** 初始化思维导图 */
|
/** 初始化思维导图 */
|
||||||
try {
|
try {
|
||||||
markMap = Markmap.create(svgRef.value!);
|
if (!markMap) {
|
||||||
const { el } = Toolbar.create(markMap);
|
markMap = Markmap.create(svgRef.value!);
|
||||||
toolBarRef.value?.append(el);
|
const { el } = Toolbar.create(markMap);
|
||||||
|
toolBarRef.value?.append(el);
|
||||||
|
}
|
||||||
nextTick(update);
|
nextTick(update);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('思维导图初始化失败');
|
message.error('思维导图初始化失败');
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
import type { AiMindmapApi } from '#/api/ai/mindmap';
|
import type { AiMindmapApi } from '#/api/ai/mindmap';
|
||||||
import type { SystemUserApi } from '#/api/system/user';
|
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';
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
|
@ -15,9 +15,17 @@ import { getSimpleUserList } from '#/api/system/user';
|
||||||
import { DocAlert } from '#/components/doc-alert';
|
import { DocAlert } from '#/components/doc-alert';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import Right from '../index/modules/Right.vue';
|
||||||
import { useGridColumns, useGridFormSchema } from './data';
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
|
||||||
const userList = ref<SystemUserApi.User[]>([]); // 用户列表
|
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() {
|
function onRefresh() {
|
||||||
gridApi.query();
|
gridApi.query();
|
||||||
|
|
@ -68,6 +76,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
},
|
},
|
||||||
} as VxeTableGridOptions<AiMindmapApi.MindMapVO>,
|
} 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 () => {
|
onMounted(async () => {
|
||||||
// 获得下拉数据
|
// 获得下拉数据
|
||||||
userList.value = await getSimpleUserList();
|
userList.value = await getSimpleUserList();
|
||||||
|
|
@ -77,6 +92,15 @@ onMounted(async () => {
|
||||||
<template>
|
<template>
|
||||||
<Page auto-content-height>
|
<Page auto-content-height>
|
||||||
<DocAlert title="AI 思维导图" url="https://doc.iocoder.cn/ai/mindmap/" />
|
<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="思维导图管理列表">
|
<Grid table-title="思维导图管理列表">
|
||||||
<template #toolbar-tools>
|
<template #toolbar-tools>
|
||||||
<TableAction :actions="[]" />
|
<TableAction :actions="[]" />
|
||||||
|
|
@ -89,6 +113,13 @@ onMounted(async () => {
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<TableAction
|
<TableAction
|
||||||
:actions="[
|
: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'),
|
label: $t('common.delete'),
|
||||||
type: 'link',
|
type: 'link',
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,28 +1,128 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
import { Page } from '@vben/common-ui';
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page>
|
<Page auto-content-height>
|
||||||
<Button
|
<Grid table-title="AI 工作流列表">
|
||||||
danger
|
<template #toolbar-tools>
|
||||||
type="link"
|
<TableAction
|
||||||
target="_blank"
|
:actions="[
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
{
|
||||||
>
|
label: $t('ui.actionTitle.create', ['AI 工作流']),
|
||||||
该功能支持 Vue3 + element-plus 版本!
|
type: 'primary',
|
||||||
</Button>
|
icon: ACTION_ICON.ADD,
|
||||||
<br />
|
auth: ['ai:workflow:create'],
|
||||||
<Button
|
onClick: handleCreate,
|
||||||
type="link"
|
},
|
||||||
target="_blank"
|
]"
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/workflow/index.vue"
|
/>
|
||||||
>
|
</template>
|
||||||
可参考
|
<template #actions="{ row }">
|
||||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/workflow/index.vue
|
<TableAction
|
||||||
代码,pull request 贡献给我们!
|
:actions="[
|
||||||
</Button>
|
{
|
||||||
|
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>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// markmap的tool样式覆盖
|
||||||
|
:deep(.markmap) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.mm-toolbar-brand) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.mm-toolbar) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,28 +1,86 @@
|
||||||
<script lang="ts" setup>
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page>
|
<Page auto-content-height>
|
||||||
<Button
|
<div class="absolute bottom-0 left-0 right-0 top-0 flex">
|
||||||
danger
|
<Left
|
||||||
type="link"
|
:is-writing="isWriting"
|
||||||
target="_blank"
|
class="h-full"
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
@submit="submit"
|
||||||
>
|
@reset="reset"
|
||||||
该功能支持 Vue3 + element-plus 版本!
|
@example="handleExampleClick"
|
||||||
</Button>
|
/>
|
||||||
<br />
|
<Right
|
||||||
<Button
|
:is-writing="isWriting"
|
||||||
type="link"
|
@stop-stream="stopStream"
|
||||||
target="_blank"
|
ref="rightRef"
|
||||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/write/index/index.vue"
|
class="flex-grow"
|
||||||
>
|
v-model:content="writeResult"
|
||||||
可参考
|
/>
|
||||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/write/index/index.vue
|
</div>
|
||||||
代码,pull request 贡献给我们!
|
|
||||||
</Button>
|
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export default defineConfig(async () => {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/admin-api/, ''),
|
rewrite: (path) => path.replace(/^\/admin-api/, ''),
|
||||||
// mock代理目标地址
|
// mock代理目标地址
|
||||||
target: 'http://localhost:48080/admin-api',
|
target: 'http://192.168.2.107:48080/admin-api',
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue