This commit is contained in:
吴红兵
2025-12-02 10:37:49 +08:00
commit 1f645dad3e
1183 changed files with 147673 additions and 0 deletions

View File

@@ -0,0 +1,324 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" v-model="visible" :width="800" :close-on-click-modal="false" draggable :destroy-on-close="true" class="dark:bg-gray-800">
<el-form
ref="dataFormRef"
:model="form"
:rules="dataRules"
formDialogRef
@submit.prevent
label-width="90px"
v-loading="loading"
class="dark:text-gray-300"
>
<el-collapse v-model="activeNames" class="dark:border-gray-700">
<el-row :gutter="24">
<el-col :span="12" class="mt-8 mb20">
<el-form-item label="知识库" prop="datasetId" class="dark:text-gray-300">
<el-select v-model="form.datasetId" placeholder="请选择知识库" class="dark:bg-gray-700">
<el-option v-for="item in datasetList" :key="item.id" :label="item.name" :value="item.id" class="dark:hover:bg-gray-600" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" class="mt-8 mb20">
<el-form-item label="来源" prop="sourceType" class="dark:text-gray-300">
<el-radio-group v-model="form.sourceType" class="dark:text-gray-300">
<el-radio-button
v-for="item in source_type"
:key="item.value"
:label="item.value"
border
class="dark:border-gray-600 dark:text-gray-300"
>
{{ item.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<TextDocumentForm v-if="form.sourceType === '2'" v-model="form" />
<FileDocumentForm v-else-if="form.sourceType === '1'" v-model="form.files" />
<QADocumentForm v-else-if="form.sourceType === '3'" v-model="form.files" />
<CrawlerDocumentForm v-else-if="form.sourceType === '4'" v-model="form" />
<el-collapse-item name="3" class="dark:border-gray-700" :disabled="false">
<template #title>
<div class="flex items-center mb-3 font-medium text-gray-700 dark:text-gray-300">
<el-icon class="mr-1"><Setting /></el-icon>
分片设置
</div>
</template>
<el-row :gutter="24" class="mb-4">
<el-col :span="24">
<el-form-item label="分片算法" prop="sliceType" class="dark:text-gray-300">
<div class="flex flex-wrap gap-2 items-center">
<div
v-for="item in slice_algorithm_types"
:key="item.value"
:class="[
'cursor-pointer mr-6 border rounded-lg py-2 px-3 flex items-center transition-all hover:shadow-sm',
form.sliceType === item.value ? 'border-blue-500 shadow-sm dark:bg-gray-700' : 'border-gray-200 dark:border-gray-600',
]"
@click="form.sliceType = item.value"
>
<div
:class="[
'rounded-full w-6 h-6 flex items-center justify-center mr-2',
form.sliceType === item.value ? 'bg-blue-500' : 'bg-gray-100 dark:bg-gray-600',
]"
>
<el-icon :class="['text-lg', form.sliceType === item.value ? 'text-white' : 'text-gray-500 dark:text-gray-300']">
<component :is="getIconForType(item.value)" />
</el-icon>
</div>
<span :class="['text-sm', form.sliceType === item.value ? 'font-medium text-blue-500 dark:text-blue-400' : 'dark:text-gray-300']">
{{ item.label }}
</span>
</div>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24" class="mt-6">
<el-col :span="12">
<el-form-item label="分片值" prop="maxSegmentSizeInTokens" class="dark:text-gray-300">
<template #label>分片值 <tip content="每个分片里面字符总数量" /> </template>
<el-input-number
v-model="form.maxSegmentSizeInTokens"
:min="500"
:max="4000"
:step="100"
class="w-full dark:bg-gray-700"
placeholder="请输入分片大小"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="maxOverlapSizeInTokens" class="dark:text-gray-300">
<template #label>重叠值 <tip content="是指分片之间的重叠大小,避免分割丢失上下文" /> </template>
<el-input-number
v-model="form.maxOverlapSizeInTokens"
:min="0"
:max="200"
:step="50"
class="w-full dark:bg-gray-700"
placeholder="请输入重叠大小"
/>
</el-form-item>
</el-col>
</el-row>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false" class="dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"> 取消 </el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading" class="dark:border-gray-600"> 确认 </el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="AiDocumentDialog">
import { defineAsyncComponent } from 'vue';
import { useDict } from '/@/hooks/dict';
import { useMessage } from '/@/hooks/message';
import { getObj, addObj, putObj } from '/@/api/knowledge/aiDocument';
import { fetchDataList } from '/@/api/knowledge/aiDataset';
import { rule } from '/@/utils/validate';
import { Setting, Document, ChatLineRound, Reading, Collection, Paperclip, Operation, Link } from '@element-plus/icons-vue';
const TextDocumentForm = defineAsyncComponent(() => import('./sources/TextDocumentForm.vue'));
const FileDocumentForm = defineAsyncComponent(() => import('./sources/FileDocumentForm.vue'));
const QADocumentForm = defineAsyncComponent(() => import('./sources/QADocumentForm.vue'));
const CrawlerDocumentForm = defineAsyncComponent(() => import('./sources/CrawlerDocumentForm.vue'));
const emit = defineEmits(['refresh']);
// 定义变量内容
const { source_type } = useDict('source_type');
const dataFormRef = ref();
const route = useRoute();
const visible = ref(false);
const loading = ref(false);
const fileType = ref(['jpeg', 'png', 'jpg', 'gif', 'md', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx', 'html']);
/**
* 文档分片算法枚举
*/
enum SliceAlgorithm {
PARAGRAPH = 'paragraph', // 段落分割器
LINE = 'line', // 行分割器
SENTENCE = 'sentence', // 句子分割器
WORD = 'word', // 单词分割器
CHARACTER = 'character', // 字符分割器
REGEX = 'regex', // 正则表达式分割器
RECURSIVE = 'recursive', // 递归智能分割器
}
// 分片算法选项
const slice_algorithm_types = [
{ value: SliceAlgorithm.RECURSIVE, label: '智能分片' },
{ value: SliceAlgorithm.PARAGRAPH, label: '段落分片' },
{ value: SliceAlgorithm.SENTENCE, label: '句子分片' },
{ value: SliceAlgorithm.CHARACTER, label: '字符分片' },
];
// 提交表单数据
const form = reactive({
id: '',
name: '',
datasetId: '',
fileType: '',
content: '',
files: [],
sourceType: '1',
sliceCount: '',
hitCount: '',
fileSize: '',
fileStatus: '1',
sliceType: SliceAlgorithm.RECURSIVE,
repoType: '',
repoOwner: '',
repoName: '',
accessToken: '',
maxSegmentSizeInTokens: 1000,
maxOverlapSizeInTokens: 50,
});
// 定义校验规则
const dataRules = ref({
datasetId: [{ required: true, message: '所属知识库不能为空', trigger: 'blur' }],
name: [
{ validator: rule.overLength, trigger: 'blur' },
{ required: true, message: '文件名不能为空', trigger: 'blur' },
],
content: [{ required: true, message: '内容不能为空', trigger: 'blur' }],
url: [
{ required: true, message: '网址不能为空', trigger: 'blur' },
{ validator: rule.overLength, trigger: 'blur' },
{ type: 'url', message: '请输入正确的网址', trigger: 'blur' },
],
settings: [{ required: true, message: '请输入配置', trigger: 'change' }],
sliceType: [{ required: true, message: '请选择分片算法', trigger: 'change' }],
maxSegmentSizeInTokens: [{ required: true, message: '请输入分片大小', trigger: 'blur' }],
maxOverlapSizeInTokens: [{ required: true, message: '请输入重叠大小', trigger: 'blur' }],
files: [
{
validator: (rule: any, value: any, callback: any) => {
if (form.sourceType === '1' && (!form.files || form.files.length === 0)) {
callback(new Error('文件不能为空'));
} else {
callback();
}
},
trigger: 'change',
},
],
});
// 打开弹窗
const openDialog = (id: string) => {
visible.value = true;
form.id = '';
form.files = [];
// 重置表单数据
nextTick(() => {
dataFormRef.value?.resetFields();
});
getDatasetList();
// 获取aiDocument信息
if (id) {
form.id = id;
getAiDocumentData(id);
}
};
// 监听 form.sourceType 变化,如果 sourceType === 1 则打开 excelUploadRef.show()
watch(
() => form.sourceType,
(value) => {
if (value === '3') {
fileType.value = ['xlsx'];
} else {
fileType.value = ['jpeg', 'png', 'jpg', 'gif', 'md', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx', 'html'];
}
}
);
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {});
if (!valid) return false;
try {
loading.value = true;
form.id ? await putObj(form) : await addObj(form);
useMessage().success(form.id ? '修改成功' : '添加成功');
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg);
} finally {
loading.value = false;
}
};
const datasetList = ref<{ id: string; name: string }[]>([]);
const getDatasetList = async () => {
const { data } = await fetchDataList();
datasetList.value = data;
};
// 初始化表单数据
const getAiDocumentData = (id: string) => {
// 获取数据
loading.value = true;
getObj(id)
.then((res: any) => {
Object.assign(form, res.data);
})
.finally(() => {
loading.value = false;
});
};
onMounted(() => {
const datasetId = route.query.datasetId;
if (typeof datasetId === 'string') {
form.datasetId = datasetId;
}
});
// 暴露变量
defineExpose({
openDialog,
});
// 新增的响应式变量
const activeNames = ref(['1', '2', '3']);
// 获取对应切片类型的图标
const getIconForType = (type: string) => {
switch (type) {
case SliceAlgorithm.PARAGRAPH:
return Document;
case SliceAlgorithm.LINE:
return Reading;
case SliceAlgorithm.SENTENCE:
return ChatLineRound;
case SliceAlgorithm.WORD:
return Collection;
case SliceAlgorithm.CHARACTER:
return Paperclip;
case SliceAlgorithm.REGEX:
return Operation;
case SliceAlgorithm.RECURSIVE:
return Link;
default:
return Document;
}
};
</script>

View File

@@ -0,0 +1,281 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row v-show="showSearch">
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="知识库名" prop="title">
<el-select placeholder="请选择知识库" v-model="state.queryForm.datasetId">
<el-option :key="index" :label="item.name" :value="item.id" v-for="(item, index) in datasetList">
{{ item.name }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="切片状态" prop="sliceStatus">
<el-select placeholder="请选择状态" v-model="state.queryForm.sliceStatus">
<el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in slice_status">
{{ item.label }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="总结状态" prop="summaryStatus">
<el-select placeholder="请选择状态" v-model="state.queryForm.summaryStatus">
<el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in summary_status">
{{ item.label }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="文件名" prop="name">
<el-input placeholder="请输入文件名" v-model="state.queryForm.name" />
</el-form-item>
<el-form-item label="文件来源" prop="sourceType">
<el-select placeholder="请选择状态" v-model="state.queryForm.sourceType">
<el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in source_type">
{{ item.label }}
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList"> 查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()" v-auth="'knowledge_aiDocument_add'">
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'knowledge_aiDocument_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar
v-model:showSearch="showSearch"
:export="'knowledge_aiDocument_export'"
@exportExcel="exportExcel"
class="ml10 mr20"
style="float: right"
@queryTable="getDataList"
></right-toolbar>
</div>
</el-row>
<el-table
:data="state.dataList"
v-loading="state.loading"
border
@row-dblclick="go2slice"
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
@selection-change="selectionChangHandle"
@sort-change="sortChangeHandle"
>
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="name" label="名称" width="200" show-overflow-tooltip />
<el-table-column prop="fileType" label="文件类型" show-overflow-tooltip />
<el-table-column prop="sourceType" label="文件来源" show-overflow-tooltip>
<template #default="scope">
<dict-tag :options="source_type" :value="scope.row.sourceType"></dict-tag>
</template>
</el-table-column>
<el-table-column prop="sliceCount" label="切片数量" show-overflow-tooltip />
<el-table-column prop="hitCount" label="命中次数" show-overflow-tooltip />
<el-table-column prop="fileStatus" width="100" show-overflow-tooltip>
<template #header>
切片结果
<tip content="点击【失败】标签可查看失败原因" />
</template>
<template #default="scope">
<template v-if="scope.row.sliceStatus === '9'">
<el-tooltip placement="top">
<template #content>{{ scope.row.sliceFailReason }}</template>
<dict-tag :options="slice_status" :value="scope.row.sliceStatus" />
</el-tooltip>
</template>
<template v-else>
<dict-tag :options="slice_status" :value="scope.row.sliceStatus" />
</template>
</template>
</el-table-column>
<el-table-column prop="summaryStatus" width="100" show-overflow-tooltip>
<template #header>
总结结果
<tip content="点击【失败】标签可查看失败原因" />
</template>
<template #default="scope">
<template v-if="scope.row.summaryStatus === '9'">
<el-tooltip placement="top">
<template #content>{{ scope.row.summaryFailReason }}</template>
<dict-tag :options="summary_status" :value="scope.row.summaryStatus" />
</el-tooltip>
</template>
<template v-else>
<dict-tag :options="summary_status" :value="scope.row.summaryStatus" />
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="300">
<template #default="scope">
<el-button icon="View" text type="primary" @click="viewDocument(scope.row)" :disabled="scope.row.sliceStatus !== '1'">文档</el-button>
<el-button icon="Refresh" text type="primary" @click="retry2slice(scope.row)">重试</el-button>
<el-button icon="edit-pen" text type="primary" v-auth="'knowledge_aiDocument_del'" @click="go2slice(scope.row)">切片</el-button>
<el-button icon="delete" text type="primary" v-auth="'knowledge_aiDocument_del'" @click="handleDelete([scope.row.id])">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<!-- 编辑新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
<!-- 文档查看抽屉 -->
<document-drawer
v-model="documentDrawerVisible"
:document-id="selectedDocumentId"
/>
</div>
</template>
<script setup lang="ts" name="systemAiDocument">
import { BasicTableProps, useTable } from '/@/hooks/table';
import { fetchList, delObjs, retrySlice, retryIssue } from '/@/api/knowledge/aiDocument';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { useDict } from '/@/hooks/dict';
import { fetchDataList } from '/@/api/knowledge/aiDataset';
import DocumentDrawer from '../aiSlice/components/DocumentDrawer.vue';
const route = useRoute();
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
// 定义查询字典
const { source_type, slice_status, summary_status } = useDict('yes_no_type', 'source_type', 'slice_status', 'summary_status');
const router = useRouter();
// 定义变量内容
const formDialogRef = ref();
// 搜索变量
const queryRef = ref();
const showSearch = ref(true);
// 多选变量
const selectObjs = ref([]) as any;
const multiple = ref(true);
const state: BasicTableProps = reactive<BasicTableProps>({
createdIsNeed: false,
queryForm: {},
pageList: fetchList,
});
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state);
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields();
state.queryForm = {};
// 清空多选
selectObjs.value = [];
getDataList();
};
// 导出excel
const exportExcel = () => {
downBlobFile('/knowledge/aiDocument/export', Object.assign(state.queryForm, { ids: selectObjs }), 'aiDocument.xlsx');
};
// 多选事件
const selectionChangHandle = (objs: { id: string }[]) => {
selectObjs.value = objs.map(({ id }) => id);
multiple.value = !objs.length;
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs(ids);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
useMessage().error(err.msg);
}
};
const datasetList = ref([]);
/**
* 获取知识库列表数据
* 通过API调用获取所有可用的知识库
*/
const getDatasetList = async () => {
const { data } = await fetchDataList();
datasetList.value = data;
};
onMounted(async () => {
await getDatasetList();
if (route.query.datasetId) {
state.queryForm.datasetId = route.query.datasetId;
}
// 查询表格数据
await getDataList();
});
/**
* 重新执行文档切片
* @param document 需要重新切片的文档对象
*/
const retry2slice = async (document: any) => {
try {
await useMessageBox().confirm('此操作将重新切片,删除原有切片数据');
} catch {
return;
}
try {
await retrySlice(document);
useMessage().success('操作成功,稍后请刷新列表查看');
} catch (err: any) {
useMessage().error(err.msg);
}
};
// 文档查看抽屉
const documentDrawerVisible = ref(false);
const selectedDocumentId = ref('');
/**
* 跳转到文档切片页面
* @param document 要查看/编辑切片的文档对象
*/
const go2slice = (document: any) => {
router.push({
path: '/knowledge/aiSlice/index',
query: {
documentId: document.id,
},
});
};
/**
* 查看完整文档
* @param document 要查看的文档对象
*/
const viewDocument = (document: any) => {
// 只有切片成功的文档才能查看
if (document.sliceStatus !== '1') {
useMessage().warning('只有切片成功的文档才能查看');
return;
}
selectedDocumentId.value = document.id;
documentDrawerVisible.value = true;
};
</script>

View File

@@ -0,0 +1,79 @@
<template>
<el-col class="mb20">
<el-form-item label="文件名" prop="name">
<el-input placeholder="请输入文件名" v-model="modelValue.name" />
</el-form-item>
</el-col>
<el-col class="mb20">
<el-form-item label="网址" prop="url">
<el-input placeholder="请输入网址" v-model="modelValue.url">
<template #append>
<el-button :icon="Search" :loading="loading" @click="handleParse"></el-button>
<el-divider direction="vertical" class="mx-4" />
<el-button :icon="Setting" @click="handleToggleSettings"></el-button>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col class="mb20" v-if="showSettings">
<el-form-item prop="settings">
<template #label>目标<tip content="请输入目标元素class例如 .class1"></tip> </template>
<el-input show-word-limit v-model="modelValue.settings" />
</el-form-item>
</el-col>
<el-col class="mb20">
<el-form-item label="内容" prop="content">
<el-input type="textarea" rows="10" maxlength="3000" show-word-limit v-model="modelValue.content" />
</el-form-item>
</el-col>
</template>
<script setup lang="ts">
import { ref, PropType } from 'vue';
import { Search, Setting } from '@element-plus/icons-vue';
// @ts-ignore
import JsonEditor from '@axolo/json-editor-vue';
import { crawleObj } from '/@/api/knowledge/aiDocument';
import { ElMessage } from 'element-plus';
const props = defineProps({
modelValue: {
type: Object as PropType<any>,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const showSettings = ref(false);
const loading = ref(false);
const handleToggleSettings = () => {
showSettings.value = !showSettings.value;
};
const handleParse = async () => {
if (loading.value) return;
if (!props.modelValue.url) {
ElMessage.warning('请输入网址');
return;
}
loading.value = true;
try {
const payload = {
url: props.modelValue.url,
settings: props.modelValue.settings,
};
const { data } = await crawleObj(payload);
props.modelValue.content = data;
emit('update:modelValue', { ...props.modelValue, content: data });
ElMessage.success('解析成功');
} catch (error) {
ElMessage.error('解析失败,请检查后台服务或接口定义');
} finally {
loading.value = false;
}
};
</script>

View File

@@ -0,0 +1,29 @@
<template>
<el-col class="mb20">
<el-form-item label="资料" prop="files">
<upload-file :limit="fileLimit" :fileSize="fileSize" :fileType="fileType" @change="handleFileChange" />
</el-form-item>
</el-col>
</template>
<script setup lang="ts">
const emit = defineEmits(['update:modelValue']);
const props = defineProps({
modelValue: {
type: Object,
},
});
// 单此上传文件数量限制
const fileLimit = ref(5);
// 单个文件大小限制
const fileSize = ref(10);
// 文件类型限制
const fileType = ref(['jpeg', 'png', 'jpg', 'gif', 'md', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx']);
const handleFileChange = (fileNames: string, fileList: any[]) => {
emit('update:modelValue', fileList);
};
</script>

View File

@@ -0,0 +1,34 @@
<template>
<el-col class="mb20">
<el-form-item label="资料" prop="files">
<upload-file
:limit="1"
@change="handleFileChange"
:fileType="['xlsx']"
/>
<a class="link link-primary" @click="downloadTemplate">Q&A Excel 模板下载</a>
</el-form-item>
</el-col>
</template>
<script setup lang="ts">
import { PropType } from 'vue';
import { downBlobFile } from "/@/utils/other";
const props = defineProps({
modelValue: {
type: Object as PropType<any>,
required: true
}
});
const emit = defineEmits(['update:modelValue']);
const handleFileChange = (fileNames: string, fileList: any[]) => {
emit('update:modelValue', fileList);
}
const downloadTemplate = () => {
downBlobFile('/admin/sys-file/local/file/qa.xlsx', {}, 'Q&A.xlsx');
};
</script>

View File

@@ -0,0 +1,30 @@
<template>
<el-col class="mb20">
<el-form-item label="文件名" prop="name">
<el-input placeholder="请输入文件名" v-model="modelValue.name" />
</el-form-item>
</el-col>
<el-col class="mb20">
<el-form-item label="内容" prop="content">
<ai-editor
v-model="modelValue.content"
output="text"
placeholder="选择输入文本,即可调用 AI 辅助功能"
:minHeight="400"
/>
</el-form-item>
</el-col>
</template>
<script setup lang="ts">
import { PropType } from 'vue';
defineProps({
modelValue: {
type: Object as PropType<any>,
required: true,
},
});
defineEmits(['update:modelValue']);
</script>