KnowledgeBaseEditModal.vue 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244
  1. <template>
  2. <el-dialog
  3. v-model="drawerVisible"
  4. :title="editingId ? '编辑知识库' : '新建知识库'"
  5. @open="handleOpen"
  6. class="knowledge-modal"
  7. fullscreen
  8. >
  9. <el-scrollbar class="modal-wrap">
  10. <el-form
  11. ref="formRef"
  12. :model="form"
  13. :rules="rules"
  14. label-position="left"
  15. label-width="120px"
  16. class="knowledge-form"
  17. >
  18. <el-tabs v-model="activeTab" tab-position="left" class="settings-tabs">
  19. <el-tab-pane label="基础信息" name="basic">
  20. <div class="tab-intro">设置知识库的名称、类型和描述信息</div>
  21. <div class="collapse-body">
  22. <el-form-item label="知识库类型" prop="type">
  23. <div class="switch-wrap">
  24. <el-segmented
  25. v-model="form.type"
  26. class="selection-segmented"
  27. :options="knowledgeTypeOptions"
  28. @change="handleKnowledgeBaseTypeChange"
  29. />
  30. <div class="field-tip">
  31. FAQ 类型适合结构化问答数据;文档型支持文件解析与分块。
  32. </div>
  33. </div>
  34. </el-form-item>
  35. <div v-if="isDocumentType" class="subsection ml-120px mb-16px">
  36. <div class="subsection-title">索引策略</div>
  37. <div class="subsection-tip">
  38. 配置文档上传后的处理管道。关键词和向量检索为 RAG 必需项,系统会保持开启。
  39. </div>
  40. <div class="index-strategy-list">
  41. <el-form-item label="知识图谱启用">
  42. <div class="switch-wrap">
  43. <el-segmented
  44. v-model="form.graph_enabled"
  45. class="selection-segmented"
  46. :options="booleanSegmentedOptions"
  47. />
  48. <div class="field-tip">开启后可使用图谱关系辅助检索。</div>
  49. </div>
  50. </el-form-item>
  51. <el-form-item label="关键字启用">
  52. <div class="switch-wrap">
  53. <el-segmented
  54. v-model="form.keyword_enabled"
  55. class="selection-segmented"
  56. :options="booleanSegmentedOptions"
  57. disabled
  58. />
  59. <div class="field-tip">RAG 检索必须开启,系统会自动保持为开启状态。</div>
  60. </div>
  61. </el-form-item>
  62. <el-form-item label="向量启用">
  63. <div class="switch-wrap">
  64. <el-segmented
  65. v-model="form.vector_enabled"
  66. class="selection-segmented"
  67. :options="booleanSegmentedOptions"
  68. disabled
  69. />
  70. <div class="field-tip">RAG 检索必须开启,系统会自动保持为开启状态。</div>
  71. </div>
  72. </el-form-item>
  73. <el-form-item label="维基启用">
  74. <div class="switch-wrap">
  75. <el-segmented
  76. v-model="form.wiki_enabled"
  77. class="selection-segmented"
  78. :options="booleanSegmentedOptions"
  79. />
  80. <div class="field-tip">开启后可从 Wiki 内容中补充知识来源。</div>
  81. </div>
  82. </el-form-item>
  83. </div>
  84. </div>
  85. <el-form-item label="知识库名称" prop="name">
  86. <el-input v-model="form.name" placeholder="请输入知识库名称" />
  87. </el-form-item>
  88. <el-form-item label="知识库描述">
  89. <el-input
  90. v-model="form.description"
  91. type="textarea"
  92. :rows="3"
  93. placeholder="请输入知识库描述"
  94. />
  95. </el-form-item>
  96. </div>
  97. </el-tab-pane>
  98. <el-tab-pane label="模型配置" name="model">
  99. <div class="tab-intro">为知识库选择合适的 AI 模型</div>
  100. <div class="collapse-body">
  101. <el-form-item label="LLM大语言模型" prop="summary_model_id">
  102. <el-select v-model="form.summary_model_id" placeholder="请选择">
  103. <el-option
  104. v-for="item in summaryModels"
  105. :key="item.id"
  106. :label="buildModelLabel(item)"
  107. :value="item.id"
  108. />
  109. </el-select>
  110. <div class="field-tip">用于总结和摘要的大语言模型。</div>
  111. </el-form-item>
  112. <el-form-item label="Embedding 嵌入模型" prop="embedding_model_id">
  113. <el-select v-model="form.embedding_model_id" placeholder="请选择">
  114. <el-option
  115. v-for="item in embeddingModels"
  116. :key="item.id"
  117. :label="buildModelLabel(item)"
  118. :value="item.id"
  119. />
  120. </el-select>
  121. <div class="field-tip">用于文本向量化的嵌入模型。</div>
  122. </el-form-item>
  123. </div>
  124. </el-tab-pane>
  125. <el-tab-pane label="向量存储" name="vectorStore">
  126. <div class="tab-intro">从全局向量存储配置中选择, 可为空, 默认/为空:系统默认。</div>
  127. <div class="collapse-body">
  128. <el-form-item label="向量存储">
  129. <el-select
  130. v-model="form.vector_store_id"
  131. placeholder="系统默认"
  132. clearable
  133. :disabled="!!editingId"
  134. >
  135. <el-option
  136. v-for="item in vectorStoreOptions"
  137. :key="item.value"
  138. :label="item.label"
  139. :value="item.value"
  140. />
  141. </el-select>
  142. <div class="field-tip">
  143. 创建后不可更改。如需迁移,请创建一个绑定到目标存储的新 KB 并重新索引。
  144. </div>
  145. </el-form-item>
  146. </div>
  147. </el-tab-pane>
  148. <el-tab-pane v-if="isDocumentType" label="解析引擎" name="parser">
  149. <div class="tab-intro">为不同文件类型选择文档解析引擎</div>
  150. <div class="collapse-body">
  151. <div class="field-tip compact-tip">未配置的文件类型将使用内置解析引擎。</div>
  152. <div class="parser-rule-list">
  153. <div
  154. v-for="rule in form.parser_engine_rules"
  155. :key="rule.file_types.join(',')"
  156. class="parser-rule-item"
  157. >
  158. <div class="parser-rule-item__types">{{ rule.file_types.join(', ') }}</div>
  159. <el-select v-model="rule.engine" placeholder="请选择">
  160. <el-option
  161. v-for="item in parserEngineOptions"
  162. :key="item"
  163. :label="item"
  164. :value="item"
  165. />
  166. </el-select>
  167. </div>
  168. </div>
  169. </div>
  170. </el-tab-pane>
  171. <el-tab-pane v-if="isDocumentType" label="图像处理" name="image">
  172. <div class="tab-intro">配置图像内容理解能力</div>
  173. <div class="collapse-body">
  174. <el-form-item label="多模态功能">
  175. <div class="switch-wrap">
  176. <el-segmented
  177. v-model="form.vlm_enabled"
  178. class="selection-segmented"
  179. :options="booleanSegmentedOptions"
  180. />
  181. <div class="field-tip">启用图片等多模态内容的理解能力。</div>
  182. </div>
  183. </el-form-item>
  184. <el-form-item v-if="form.vlm_enabled" label="VLLM视觉模型">
  185. <el-select v-model="form.vlm_model_id" placeholder="请选择">
  186. <el-option
  187. v-for="item in vlmModels"
  188. :key="item.id"
  189. :label="buildModelLabel(item)"
  190. :value="item.id"
  191. />
  192. </el-select>
  193. <div class="field-tip">用于多模态理解的视觉语言模型(必选)。</div>
  194. </el-form-item>
  195. </div>
  196. </el-tab-pane>
  197. <el-tab-pane v-if="isDocumentType" label="音频处理" name="audio">
  198. <div class="tab-intro">配置语音识别(ASR)</div>
  199. <div class="collapse-body">
  200. <div class="field-tip compact-tip">
  201. 启用后可上传音频文件并整段转写为文本(常见格式:mp3、wav、m4a、flac、ogg
  202. 等)。暂不支持视频上传。
  203. </div>
  204. <el-form-item label="音频语音识别">
  205. <div class="switch-wrap">
  206. <el-segmented
  207. v-model="form.asr_enabled"
  208. class="selection-segmented"
  209. :options="booleanSegmentedOptions"
  210. />
  211. <div class="field-tip">
  212. 启用后可上传音频到知识库,系统自动将语音转写为文本并参与解析与检索。
  213. </div>
  214. </div>
  215. </el-form-item>
  216. <el-form-item v-if="form.asr_enabled" label="ASR模型">
  217. <el-select v-model="form.asr_model_id" placeholder="请选择">
  218. <el-option
  219. v-for="item in asrModels"
  220. :key="item.id"
  221. :label="buildModelLabel(item)"
  222. :value="item.id"
  223. />
  224. </el-select>
  225. <div class="field-tip">用于音频中语音转文本的识别模型(如 OpenAI Whisper)。</div>
  226. </el-form-item>
  227. </div>
  228. </el-tab-pane>
  229. <el-tab-pane v-if="isDocumentType" label="存储引擎" name="storage">
  230. <div class="tab-intro">选择文档上传使用的文件存储引擎</div>
  231. <div class="collapse-body">
  232. <el-form-item label="存储引擎">
  233. <el-select v-model="form.storage_provider" placeholder="请选择">
  234. <el-option
  235. v-for="item in storageProviderOptions"
  236. :key="item.value"
  237. :label="item.label"
  238. :value="item.value"
  239. />
  240. </el-select>
  241. <div class="field-tip">
  242. 选择该知识库使用的存储引擎,需在全局设置中已配置对应引擎。
  243. </div>
  244. </el-form-item>
  245. </div>
  246. </el-tab-pane>
  247. <el-tab-pane v-if="isDocumentType" label="分块设置" name="chunk">
  248. <div class="tab-intro">配置文档分块参数,优化检索效果</div>
  249. <div class="collapse-body">
  250. <el-form-item label="分块策略">
  251. <div class="slider-wrap">
  252. <div class="field-tip mb-4px mt-0">
  253. 选择文档的分块方式。自动模式会分析每个文档的结构并选择最佳策略。
  254. </div>
  255. <el-segmented
  256. v-model="form.chunk_strategy"
  257. class="selection-segmented"
  258. :options="chunkStrategyOptions"
  259. />
  260. <!-- <el-select
  261. v-model="form.chunk_strategy"
  262. filterable
  263. default-first-option
  264. placeholder="请选择"
  265. :options="chunkStrategyOptions"
  266. /> -->
  267. <div class="field-tip">
  268. {{ chunkSrategyTip?.[form.chunk_strategy] }}
  269. </div>
  270. </div>
  271. </el-form-item>
  272. <el-form-item label="分块大小">
  273. <div class="slider-wrap">
  274. <el-slider
  275. v-model="form.chunk_size"
  276. :min="100"
  277. :max="4000"
  278. :step="50"
  279. show-input
  280. />
  281. <div class="field-tip">控制每个文档分块的字符数(100-4000)。</div>
  282. </div>
  283. </el-form-item>
  284. <el-form-item label="分块重叠">
  285. <div class="slider-wrap">
  286. <el-slider
  287. v-model="form.chunk_overlap"
  288. :min="0"
  289. :max="500"
  290. :step="10"
  291. show-input
  292. />
  293. <div class="field-tip">相邻文档块之间的重叠字符数(0-500)。</div>
  294. </div>
  295. </el-form-item>
  296. <el-form-item label="分隔符">
  297. <el-select
  298. v-model="form.separators"
  299. multiple
  300. allow-create
  301. filterable
  302. default-first-option
  303. placeholder="请输入或选择分隔符"
  304. >
  305. <el-option
  306. v-for="item in separatorOptions"
  307. :key="item"
  308. :label="`${item.label}(${item.displayValue})`"
  309. :value="item.value"
  310. />
  311. </el-select>
  312. <div class="field-tip">文档分块时使用的分隔符。</div>
  313. </el-form-item>
  314. <el-form-item label="父子分块">
  315. <div class="switch-wrap">
  316. <el-segmented
  317. v-model="form.enable_parent_child"
  318. class="selection-segmented"
  319. :options="booleanSegmentedOptions"
  320. />
  321. <div class="field-tip">
  322. 启用两级父子分块策略。大的父块提供上下文,小的子块用于向量匹配检索。
  323. </div>
  324. </div>
  325. </el-form-item>
  326. <div v-if="form.enable_parent_child" class="subsection ml-120px">
  327. <div class="subsection-title">父子分块参数</div>
  328. <el-form-item label="父块大小">
  329. <div class="slider-wrap">
  330. <el-slider
  331. v-model="form.parent_chunk_size"
  332. :min="512"
  333. :max="8192"
  334. :step="128"
  335. show-input
  336. />
  337. <div class="field-tip">提供上下文的父块字符数(512-8192)。</div>
  338. </div>
  339. </el-form-item>
  340. <el-form-item label="子块大小">
  341. <div class="slider-wrap">
  342. <el-slider
  343. v-model="form.child_chunk_size"
  344. :min="64"
  345. :max="2048"
  346. :step="64"
  347. show-input
  348. />
  349. <div class="field-tip">用于向量匹配的子块字符数(64-2048)。</div>
  350. </div>
  351. </el-form-item>
  352. </div>
  353. <el-collapse class="chunk-advanced-collapse mt-12px" expand-icon-position="left">
  354. <el-collapse-item title="高级" name="advanced">
  355. <div class="chunk-advanced-panel">
  356. <el-form-item label="Token 上限">
  357. <div class="slider-wrap">
  358. <el-input-number
  359. v-model="form.token_limit"
  360. :min="0"
  361. :max="8192"
  362. :step="1"
  363. style="width: 100%"
  364. />
  365. <div class="field-tip">
  366. 每个分块的硬性 Token 上限(0-8192)。0 = 关闭(仅按字符数)。当嵌入模型
  367. Token 上限较小时启用:MiniLM (256 tok) 用 200,BGE/Cohere (512 tok) 用
  368. 400。现代嵌入器(OpenAI、Voyage、Jina-v3)支持 &gt;2000 tokens,保持 0
  369. 即可。
  370. </div>
  371. </div>
  372. </el-form-item>
  373. <el-form-item label="语言提示">
  374. <div class="switch-wrap">
  375. <el-select
  376. v-model="form.languages"
  377. multiple
  378. clearable
  379. collapse-tags
  380. collapse-tags-tooltip
  381. placeholder="选择语言提示"
  382. >
  383. <el-option
  384. v-for="item in languageOptions"
  385. :key="item.value"
  386. :label="item.label"
  387. :value="item.value"
  388. />
  389. </el-select>
  390. <div class="field-tip">
  391. 限制启发式模式只识别选定的语言(DE/EN/ZH)。留空 =
  392. 自动检测。同质化语料库可显式设置以避免跨语言误匹配。
  393. </div>
  394. </div>
  395. </el-form-item>
  396. </div>
  397. </el-collapse-item>
  398. </el-collapse>
  399. </div>
  400. </el-tab-pane>
  401. <el-tab-pane v-if="isDocumentType" label="高级设置" name="advanced">
  402. <div class="tab-intro">配置问题生成等高级功能</div>
  403. <div class="collapse-body">
  404. <el-form-item label="AI 问题生成">
  405. <div class="switch-wrap">
  406. <el-segmented
  407. v-model="form.question_generation_enabled"
  408. class="selection-segmented"
  409. :options="booleanSegmentedOptions"
  410. />
  411. <div class="field-tip">
  412. 解析文档时调用大模型为每个分块生成相关问题,提高检索召回率。启用后会增加文档解析耗时。
  413. </div>
  414. </div>
  415. </el-form-item>
  416. <el-form-item v-if="form.question_generation_enabled" label="生成问题数量">
  417. <el-input-number
  418. v-model="form.question_count"
  419. :min="1"
  420. :max="10"
  421. style="width: 100%"
  422. />
  423. <div class="field-tip">每个文档分块生成的问题数量(1-10)。</div>
  424. </el-form-item>
  425. </div>
  426. </el-tab-pane>
  427. <el-tab-pane v-if="!isDocumentType" label="FAQ设置" name="faq">
  428. <div class="tab-intro">设置 FAQ 知识库的索引策略和问答组织方式</div>
  429. <div class="collapse-body">
  430. <el-form-item label="索引方式">
  431. <div class="switch-wrap">
  432. <el-segmented
  433. v-model="form.faq_index_mode"
  434. class="selection-segmented"
  435. :options="faqIndexModeOptions"
  436. />
  437. <div class="field-tip">仅索引问题可提升精度,索引问答可提高召回率。</div>
  438. </div>
  439. </el-form-item>
  440. <el-form-item label="问题索引方式">
  441. <div class="switch-wrap">
  442. <el-segmented
  443. v-model="form.faq_question_index_mode"
  444. class="selection-segmented"
  445. :options="faqQuestionIndexModeOptions"
  446. />
  447. <div class="field-tip">
  448. 合并索引:标准问和相似问合并索引;分别索引:标准问和每个相似问独立索引,检索更精确但需要更多存储。
  449. </div>
  450. </div>
  451. </el-form-item>
  452. </div>
  453. </el-tab-pane>
  454. </el-tabs>
  455. </el-form>
  456. </el-scrollbar>
  457. <template #footer>
  458. <div class="drawer-footer">
  459. <el-button @click="drawerVisible = false">取消</el-button>
  460. <el-button type="primary" :loading="submitLoading" @click="submitForm">保存</el-button>
  461. </div>
  462. </template>
  463. </el-dialog>
  464. </template>
  465. <script setup lang="ts">
  466. import { computed, onMounted, reactive, ref } from 'vue'
  467. import { ElMessage } from 'element-plus'
  468. import { aiModel, knowledge, storageProvider, vector } from '@repo/api-service'
  469. import type { KnowledgeBaseForm, KnowledgeModelOption, ParserEngineRule } from '../types'
  470. const emit = defineEmits<{ (e: 'refresh'): void }>()
  471. const submitLoading = ref(false)
  472. const drawerVisible = ref(false)
  473. const editingId = ref('')
  474. const activeTab = ref('basic')
  475. const modelList = ref<KnowledgeModelOption[]>([])
  476. const formRef = ref()
  477. const DEFAULT_PARSER_RULES: ParserEngineRule[] = [
  478. { engine: 'builtin', file_types: ['pdf'] },
  479. { engine: 'builtin', file_types: ['docx', 'doc'] },
  480. { engine: 'markitdown', file_types: ['pptx', 'ppt'] },
  481. { engine: 'builtin', file_types: ['xlsx', 'xls'] },
  482. { engine: 'simple', file_types: ['csv'] },
  483. { engine: 'builtin', file_types: ['md', 'markdown'] },
  484. { engine: 'simple', file_types: ['txt'] },
  485. { engine: 'simple', file_types: ['json'] },
  486. { engine: 'builtin', file_types: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp'] },
  487. { engine: 'simple', file_types: ['mp3', 'wav', 'm4a', 'flac', 'ogg'] }
  488. ]
  489. const separatorOptions = [
  490. { label: '双换行', value: '\n\n', displayValue: '\\n\\n' },
  491. { label: '单换行', value: '\n', displayValue: '\\n' },
  492. { label: '中文句号', value: '。', displayValue: '。' },
  493. { label: '感叹号', value: '!', displayValue: '!' },
  494. { label: '问号', value: '?', displayValue: '?' },
  495. { label: '英文分号', value: ';', displayValue: ';' },
  496. { label: '中文分号', value: ';', displayValue: ';' },
  497. { label: '空格', value: ' ', displayValue: ' ' }
  498. ]
  499. // 默认不选空格
  500. const DEFAULT_SEPARATORS = separatorOptions.map((item) => item.value).filter((item) => item !== ' ')
  501. const parserEngineOptions = ['builtin', 'markitdown', 'simple']
  502. const languageOptions = [
  503. { label: '中文', value: 'zh' },
  504. { label: '英语', value: 'en' },
  505. { label: '德语', value: 'de' }
  506. ]
  507. const storageProviderOptions = ref<{ label: string; value: string }[]>([])
  508. const vectorStoreOptions = ref<{ label: string; value: string }[]>([])
  509. const knowledgeTypeOptions = [
  510. { label: '文档', value: 'document' },
  511. { label: '问答', value: 'faq' }
  512. ]
  513. const booleanSegmentedOptions = [
  514. { label: '开启', value: true },
  515. { label: '关闭', value: false }
  516. ]
  517. const faqIndexModeOptions = [
  518. { label: '仅索引问题', value: 'question_only' },
  519. { label: '索引问答', value: 'question_answer' }
  520. ]
  521. const faqQuestionIndexModeOptions = [
  522. { label: '合并索引', value: 'combined' },
  523. { label: '分别索引', value: 'separate' }
  524. ]
  525. function cloneParserRules(rules?: ParserEngineRule[]) {
  526. return (rules || DEFAULT_PARSER_RULES).map((rule) => ({
  527. engine: rule.engine,
  528. file_types: [...rule.file_types]
  529. }))
  530. }
  531. const createDefaultForm = (): KnowledgeBaseForm => ({
  532. name: '',
  533. description: '',
  534. type: 'document',
  535. embedding_model_id: '',
  536. summary_model_id: '',
  537. faq_index_mode: 'question_only',
  538. faq_question_index_mode: 'separate',
  539. vlm_enabled: true,
  540. vlm_model_id: '',
  541. asr_enabled: false,
  542. asr_model_id: '',
  543. asr_language: '',
  544. graph_enabled: false,
  545. keyword_enabled: true,
  546. vector_enabled: true,
  547. wiki_enabled: false,
  548. question_generation_enabled: true,
  549. question_count: 3,
  550. chunk_strategy: 'auto',
  551. chunk_size: 512,
  552. chunk_overlap: 100,
  553. separators: [...DEFAULT_SEPARATORS],
  554. parser_engine_rules: cloneParserRules(),
  555. enable_parent_child: true,
  556. parent_chunk_size: 4096,
  557. child_chunk_size: 384,
  558. token_limit: 0,
  559. languages: [],
  560. storage_provider: 'local',
  561. vector_store_id: '',
  562. wiki_extraction_granularity: 'standard',
  563. wiki_max_pages_per_ingest: 0,
  564. wiki_synthesis_model_id: ''
  565. })
  566. const chunkStrategyOptions = [
  567. {
  568. label: '自动',
  569. value: 'auto'
  570. },
  571. {
  572. label: '按标题切分',
  573. value: 'heading'
  574. },
  575. {
  576. label: '结构感知',
  577. value: 'heuristic'
  578. },
  579. {
  580. label: '按长度切分',
  581. value: 'legacy'
  582. }
  583. ]
  584. const chunkSrategyTip = {
  585. auto: '文档分析器根据内容结构自动在「按标题切分」「结构感知」「按长度切分」之间选择。',
  586. heading:
  587. '在 Markdown 标题(#、##、###)边界处切分,每块自动带上所在标题路径。适合结构清晰的 Markdown 文档。',
  588. heuristic:
  589. '识别分页符、编号章节、多语言章节标记(DE/EN/ZH)、全大写标题等结构信号进行切分。适合没有 Markdown 标题的 PDF / 扫描件。',
  590. legacy: '忽略结构,仅按字符数和分隔符递归切分——原始行为。当上述策略对你的内容效果不佳时使用。'
  591. }
  592. const form = reactive<KnowledgeBaseForm>(createDefaultForm())
  593. const embeddingModels = computed(() => modelList.value.filter((item) => item.type === 'Embedding'))
  594. const summaryModels = computed(() => modelList.value.filter((item) => item.type === 'KnowledgeQA'))
  595. const vlmModels = computed(() => modelList.value.filter((item) => item.type === 'VLLM'))
  596. const asrModels = computed(() =>
  597. modelList.value.filter((item) => ['ASR', 'Asr', 'asr'].includes(item.type))
  598. )
  599. const isDocumentType = computed(() => form.type === 'document')
  600. async function fetchModels() {
  601. const res = await aiModel.postModelPageList({
  602. keyword: '',
  603. type: '',
  604. source: '',
  605. pageIndex: 1,
  606. pageSize: 200
  607. })
  608. if (res?.isSuccess) {
  609. modelList.value = (res.result?.model || []) as KnowledgeModelOption[]
  610. applyModelDefaults()
  611. }
  612. }
  613. async function fetchStorageProviders() {
  614. const res = await storageProvider.postAiStorageProviderEngines({})
  615. if (res?.isSuccess) {
  616. storageProviderOptions.value = (res.result || [])
  617. .filter((item) => {
  618. return item.allowed && item.available
  619. })
  620. .map((item) => ({ label: item.name, value: item.name }))
  621. applyModelDefaults()
  622. }
  623. }
  624. async function fetchVectorStores() {
  625. try {
  626. const res = await vector.postPageList({ keyword: '', pageIndex: 1, pageSize: 200 })
  627. if (res?.isSuccess && res.result) {
  628. const data = (res.result as any)?.model
  629. const items = Array.isArray(data) ? data : data?.list || data?.items || data?.data || []
  630. vectorStoreOptions.value = items.map((item: any) => ({
  631. label: item.name || item.display_name || item.id,
  632. value: item.id
  633. }))
  634. }
  635. } catch {
  636. // silent
  637. }
  638. }
  639. function applyModelDefaults() {
  640. if (!form.embedding_model_id && embeddingModels.value.length) {
  641. form.embedding_model_id = embeddingModels.value[0]!.id
  642. }
  643. if (!form.summary_model_id && summaryModels.value.length) {
  644. form.summary_model_id = summaryModels.value[0]!.id
  645. }
  646. if (!form.wiki_synthesis_model_id && summaryModels.value.length) {
  647. form.wiki_synthesis_model_id = summaryModels.value[0]!.id
  648. }
  649. if (vlmModels.value.length) {
  650. if (!form.vlm_model_id) form.vlm_model_id = vlmModels.value[0]!.id
  651. } else {
  652. form.vlm_enabled = false
  653. form.vlm_model_id = ''
  654. }
  655. if (asrModels.value.length) {
  656. if (!form.asr_model_id) form.asr_model_id = asrModels.value[0]!.id
  657. } else {
  658. form.asr_enabled = false
  659. form.asr_model_id = ''
  660. form.asr_language = ''
  661. }
  662. }
  663. async function hydrateSelectedModels() {
  664. const selectedIds = [
  665. form.embedding_model_id,
  666. form.summary_model_id,
  667. form.vlm_model_id,
  668. form.asr_model_id,
  669. form.wiki_synthesis_model_id
  670. ].filter(Boolean)
  671. const missingIds = Array.from(new Set(selectedIds)).filter(
  672. (id) => !modelList.value.some((item) => item.id === id)
  673. )
  674. if (!missingIds.length) return
  675. const results = await Promise.all(
  676. missingIds.map((id) => aiModel.postModelInfo({ id }).catch(() => null))
  677. )
  678. const hydratedModels = results
  679. .filter((res): res is NonNullable<typeof res> => !!res?.isSuccess && !!res.result)
  680. .map((res) => res.result as KnowledgeModelOption)
  681. if (hydratedModels.length) {
  682. modelList.value = [...modelList.value, ...hydratedModels].filter(
  683. (item, index, list) => list.findIndex((candidate) => candidate.id === item.id) === index
  684. )
  685. }
  686. }
  687. const rules = {
  688. name: [{ required: true, message: '请输入知识库名称', trigger: 'blur' }],
  689. type: [{ required: true, message: '请选择知识库类型', trigger: 'change' }],
  690. embedding_model_id: [{ required: true, message: '请选择 Embedding 模型', trigger: 'change' }],
  691. summary_model_id: [{ required: true, message: '请选择摘要模型', trigger: 'change' }]
  692. }
  693. function buildModelLabel(item: KnowledgeModelOption) {
  694. const title = item.title?.trim()
  695. return title ? `${title} (${item.name})` : item.name
  696. }
  697. function handleKnowledgeBaseTypeChange(type: KnowledgeBaseForm['type']) {
  698. activeTab.value = 'basic'
  699. if (type === 'faq') {
  700. form.graph_enabled = false
  701. form.keyword_enabled = true
  702. form.vector_enabled = true
  703. form.wiki_enabled = false
  704. form.vlm_enabled = false
  705. form.asr_enabled = false
  706. form.question_generation_enabled = false
  707. form.faq_index_mode = 'question_only'
  708. form.faq_question_index_mode = 'separate'
  709. return
  710. }
  711. form.graph_enabled = false
  712. form.keyword_enabled = true
  713. form.vector_enabled = true
  714. form.wiki_enabled = false
  715. form.question_generation_enabled = true
  716. form.question_count = 3
  717. form.chunk_size = 512
  718. form.chunk_overlap = 100
  719. form.separators = [...DEFAULT_SEPARATORS]
  720. form.parser_engine_rules = cloneParserRules()
  721. form.enable_parent_child = true
  722. form.parent_chunk_size = 4096
  723. form.child_chunk_size = 384
  724. form.token_limit = 0
  725. form.languages = []
  726. form.storage_provider = 'local'
  727. form.vector_store_id = ''
  728. form.wiki_extraction_granularity = 'standard'
  729. form.wiki_max_pages_per_ingest = 0
  730. applyModelDefaults()
  731. }
  732. function resetForm() {
  733. Object.assign(form, createDefaultForm())
  734. activeTab.value = 'basic'
  735. applyModelDefaults()
  736. }
  737. function handleOpen() {
  738. activeTab.value = 'basic'
  739. }
  740. function openCreateDrawer() {
  741. editingId.value = ''
  742. resetForm()
  743. drawerVisible.value = true
  744. }
  745. async function openEditDrawer(id: string) {
  746. editingId.value = id
  747. resetForm()
  748. const res = await knowledge.postAiKnowledgeBaseInfo({ id })
  749. if (!res?.isSuccess) return
  750. const detail = res.result
  751. const chunkingConfig = (detail.chunking_config || {}) as {
  752. strategy?: KnowledgeBaseForm['chunk_strategy']
  753. chunk_size?: number
  754. chunk_overlap?: number
  755. separators?: string[]
  756. parser_engine_rules?: ParserEngineRule[]
  757. enable_parent_child?: boolean
  758. parent_chunk_size?: number
  759. child_chunk_size?: number
  760. tokenLimit?: number
  761. languages?: string[]
  762. }
  763. Object.assign(form, {
  764. name: detail.name,
  765. description: detail.description || '',
  766. type: (detail.type || 'document') as KnowledgeBaseForm['type'],
  767. embedding_model_id: detail.embedding_model_id || '',
  768. summary_model_id: detail.summary_model_id || '',
  769. faq_index_mode: detail.faq_config?.index_mode || 'question_only',
  770. faq_question_index_mode: detail.faq_config?.question_index_mode || 'separate',
  771. vlm_enabled: detail.vlm_config?.enabled ?? false,
  772. vlm_model_id: detail.vlm_config?.model_id || '',
  773. asr_enabled: detail.asr_config?.enabled ?? false,
  774. asr_model_id: detail.asr_config?.model_id || '',
  775. asr_language: detail.asr_config?.language || '',
  776. graph_enabled: detail.indexing_strategy?.graph_enabled ?? false,
  777. keyword_enabled: true,
  778. vector_enabled: true,
  779. wiki_enabled: detail.indexing_strategy?.wiki_enabled ?? false,
  780. question_generation_enabled: detail.question_generation_config?.enabled ?? true,
  781. question_count: detail.question_generation_config?.question_count ?? 3,
  782. chunk_strategy: chunkingConfig.strategy ?? 'auto',
  783. chunk_size: chunkingConfig.chunk_size ?? 512,
  784. chunk_overlap: chunkingConfig.chunk_overlap ?? 100,
  785. separators: chunkingConfig.separators?.length
  786. ? [...chunkingConfig.separators]
  787. : [...DEFAULT_SEPARATORS],
  788. parser_engine_rules: cloneParserRules(chunkingConfig.parser_engine_rules),
  789. enable_parent_child: chunkingConfig.enable_parent_child ?? true,
  790. parent_chunk_size: chunkingConfig.parent_chunk_size ?? 4096,
  791. child_chunk_size: chunkingConfig.child_chunk_size ?? 384,
  792. token_limit: chunkingConfig.tokenLimit ?? 0,
  793. languages: chunkingConfig.languages?.length ? [...chunkingConfig.languages] : [],
  794. storage_provider:
  795. detail.storage_provider_config?.provider || detail.storage_config?.provider || 'local',
  796. vector_store_id: detail.vector_store_id || '',
  797. wiki_extraction_granularity: detail.wiki_config?.extraction_granularity || 'standard',
  798. wiki_max_pages_per_ingest: detail.wiki_config?.max_pages_per_ingest ?? 0,
  799. wiki_synthesis_model_id: detail.wiki_config?.synthesis_model_id || ''
  800. })
  801. await hydrateSelectedModels()
  802. applyModelDefaults()
  803. drawerVisible.value = true
  804. }
  805. function buildCommonPayload() {
  806. const keywordEnabled = true
  807. const vectorEnabled = true
  808. const wikiEnabled = form.wiki_enabled
  809. const vlmEnabled = vectorEnabled && form.vlm_enabled
  810. const asrEnabled = vectorEnabled && form.asr_enabled
  811. const questionGenerationEnabled = vectorEnabled && form.question_generation_enabled
  812. return {
  813. name: form.name,
  814. description: form.description,
  815. indexing_strategy: {
  816. graph_enabled: form.graph_enabled,
  817. keyword_enabled: keywordEnabled,
  818. vector_enabled: vectorEnabled,
  819. wiki_enabled: wikiEnabled
  820. },
  821. embedding_model_id: form.embedding_model_id,
  822. summary_model_id: form.summary_model_id,
  823. wiki_config: {
  824. extraction_granularity: form.wiki_extraction_granularity,
  825. max_pages_per_ingest: form.wiki_max_pages_per_ingest,
  826. synthesis_model_id: wikiEnabled
  827. ? form.wiki_synthesis_model_id || form.summary_model_id
  828. : form.summary_model_id
  829. },
  830. chunking_config: {
  831. strategy: form.chunk_strategy,
  832. chunk_size: form.chunk_size,
  833. chunk_overlap: form.chunk_overlap,
  834. separators: form.separators.length ? form.separators : [...DEFAULT_SEPARATORS],
  835. parser_engine_rules: cloneParserRules(form.parser_engine_rules),
  836. enable_parent_child: form.enable_parent_child,
  837. parent_chunk_size: form.parent_chunk_size,
  838. child_chunk_size: form.child_chunk_size,
  839. tokenLimit: form.token_limit,
  840. languages: [...form.languages]
  841. },
  842. vlm_config: {
  843. model_id: vlmEnabled ? form.vlm_model_id : '',
  844. enabled: vlmEnabled
  845. },
  846. asr_config: {
  847. model_id: asrEnabled ? form.asr_model_id : '',
  848. language: asrEnabled ? form.asr_language : '',
  849. enabled: asrEnabled
  850. },
  851. storage_provider_config: {
  852. provider: form.storage_provider
  853. },
  854. storage_config: {
  855. provider: form.storage_provider
  856. },
  857. question_generation_config: {
  858. enabled: questionGenerationEnabled,
  859. question_count: form.question_count
  860. },
  861. vector_store_id: form.vector_store_id || undefined
  862. }
  863. }
  864. function buildPayload() {
  865. const commonPayload = buildCommonPayload()
  866. if (form.type === 'faq') {
  867. return {
  868. ...commonPayload,
  869. type: form.type,
  870. faq_config: {
  871. index_mode: form.faq_index_mode,
  872. question_index_mode: form.faq_question_index_mode
  873. }
  874. }
  875. }
  876. return {
  877. ...commonPayload,
  878. type: form.type
  879. }
  880. }
  881. async function formValidate(formInstance: any) {
  882. return formInstance
  883. ?.validate()
  884. .then(() => true)
  885. .catch(() => false)
  886. }
  887. async function submitForm() {
  888. const valid = await formValidate(formRef.value)
  889. if (!valid) return
  890. submitLoading.value = true
  891. try {
  892. const payload = buildPayload()
  893. if (editingId.value) {
  894. await knowledge.postAiKnowledgeBaseUpdate({
  895. id: editingId.value,
  896. ...(payload as any)
  897. } as any)
  898. ElMessage.success('知识库已更新')
  899. } else {
  900. await knowledge.postAiKnowledgeBaseCreate(payload as any)
  901. ElMessage.success('知识库已创建')
  902. }
  903. drawerVisible.value = false
  904. emit('refresh')
  905. } catch {
  906. ElMessage.error('保存失败')
  907. } finally {
  908. submitLoading.value = false
  909. }
  910. }
  911. defineExpose({
  912. openCreateDrawer,
  913. openEditDrawer,
  914. resetForm,
  915. close: () => {
  916. resetForm()
  917. drawerVisible.value = false
  918. }
  919. })
  920. onMounted(async () => {
  921. await fetchModels()
  922. await fetchStorageProviders()
  923. await fetchVectorStores()
  924. resetForm()
  925. })
  926. </script>
  927. <style lang="less">
  928. .knowledge-modal {
  929. --agent-surface: #ffffff;
  930. --agent-surface-soft: #f7f8fc;
  931. --agent-border: rgba(148, 163, 184, 0.22);
  932. --agent-border-strong: rgba(148, 163, 184, 0.38);
  933. --agent-text: #0f172a;
  934. --agent-text-soft: #475569;
  935. --agent-accent: #14532d;
  936. --agent-shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
  937. height: 100vh;
  938. display: flex;
  939. flex-direction: column;
  940. background:
  941. radial-gradient(circle at top left, rgba(20, 83, 45, 0.08), transparent 30%),
  942. radial-gradient(circle at top right, rgba(15, 23, 42, 0.05), transparent 24%), #eff3f8;
  943. .el-dialog__body {
  944. flex: 1;
  945. overflow: auto;
  946. padding: 24px;
  947. }
  948. .el-dialog__header {
  949. padding: 20px 24px 0;
  950. }
  951. .el-dialog__title {
  952. font-size: 22px;
  953. font-weight: 700;
  954. color: var(--agent-text);
  955. letter-spacing: -0.02em;
  956. }
  957. .el-dialog__footer {
  958. padding: 0 24px 24px;
  959. }
  960. .modal-wrap {
  961. margin: 0 auto;
  962. width: min(1360px, 100%);
  963. }
  964. .knowledge-form {
  965. max-width: 100%;
  966. }
  967. :deep(.settings-tabs > .el-tabs__header) {
  968. margin-right: 20px;
  969. }
  970. :deep(.settings-tabs > .el-tabs__content) {
  971. overflow: visible;
  972. }
  973. :deep(.settings-tabs .el-tabs__item) {
  974. height: auto;
  975. padding: 14px 18px;
  976. line-height: 1.4;
  977. border-radius: 12px;
  978. }
  979. :deep(.settings-tabs .el-tabs__item.is-active) {
  980. background: rgba(20, 83, 45, 0.1);
  981. color: var(--agent-accent);
  982. }
  983. :deep(.settings-tabs .el-tab-pane) {
  984. min-height: calc(100vh - 220px);
  985. border-radius: 20px;
  986. border: 1px solid rgba(255, 255, 255, 0.65);
  987. background:
  988. linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.92)),
  989. var(--agent-surface);
  990. box-shadow: var(--agent-shadow);
  991. }
  992. .tab-intro {
  993. padding: 0 20px;
  994. font-size: 16px;
  995. color: var(--text-strong);
  996. }
  997. .collapse-body {
  998. padding: 20px;
  999. }
  1000. .switch-wrap,
  1001. .slider-wrap {
  1002. width: 100%;
  1003. }
  1004. .switch-wrap {
  1005. .field-tip {
  1006. margin-top: 0;
  1007. }
  1008. }
  1009. .selection-segmented {
  1010. align-self: flex-start;
  1011. width: fit-content;
  1012. max-width: 100%;
  1013. margin-bottom: 8px;
  1014. }
  1015. .selection-segmented :deep(.el-segmented) {
  1016. width: fit-content;
  1017. max-width: 100%;
  1018. }
  1019. .selection-segmented :deep(.el-segmented__group) {
  1020. width: auto;
  1021. }
  1022. .field-tip {
  1023. margin-top: 8px;
  1024. font-size: 13px;
  1025. line-height: 1.7;
  1026. color: var(--agent-text-soft);
  1027. }
  1028. .compact-tip {
  1029. margin-bottom: 16px;
  1030. }
  1031. .index-strategy-list {
  1032. display: grid;
  1033. gap: 10px;
  1034. margin-top: 16px;
  1035. }
  1036. .form-grid {
  1037. display: grid;
  1038. grid-template-columns: repeat(2, minmax(0, 1fr));
  1039. gap: 0 12px;
  1040. }
  1041. .subsection {
  1042. margin-top: 12px;
  1043. padding: 16px;
  1044. border: 1px solid var(--agent-border);
  1045. border-radius: 14px;
  1046. background: var(--bg-container);
  1047. }
  1048. .subsection-title {
  1049. margin-bottom: 14px;
  1050. font-size: 14px;
  1051. font-weight: 600;
  1052. color: var(--agent-text);
  1053. }
  1054. .subsection-tip {
  1055. margin: -4px 0 0;
  1056. font-size: 13px;
  1057. line-height: 1.7;
  1058. color: var(--agent-text-soft);
  1059. }
  1060. .chunk-advanced-collapse {
  1061. margin-left: 120px;
  1062. }
  1063. .chunk-advanced-panel {
  1064. padding-top: 12px;
  1065. }
  1066. .parser-rule-list {
  1067. display: grid;
  1068. gap: 12px;
  1069. }
  1070. .parser-rule-item {
  1071. display: grid;
  1072. grid-template-columns: minmax(0, 1fr) 220px;
  1073. gap: 12px;
  1074. align-items: center;
  1075. }
  1076. .parser-rule-item__types {
  1077. padding: 10px 14px;
  1078. border: 1px solid var(--agent-border);
  1079. border-radius: 12px;
  1080. background: var(--bg-container);
  1081. color: var(--agent-text);
  1082. line-height: 1.5;
  1083. }
  1084. .separator-grid {
  1085. display: grid;
  1086. grid-template-columns: repeat(2, minmax(0, 1fr));
  1087. gap: 10px;
  1088. }
  1089. .separator-chip {
  1090. display: inline-flex;
  1091. align-items: center;
  1092. justify-content: space-between;
  1093. gap: 8px;
  1094. width: 100%;
  1095. padding: 10px 12px;
  1096. border: 1px solid var(--agent-border);
  1097. border-radius: 10px;
  1098. background: var(--bg-container);
  1099. color: var(--agent-text);
  1100. font-size: 13px;
  1101. text-align: left;
  1102. cursor: pointer;
  1103. transition: all 0.2s ease;
  1104. }
  1105. .separator-chip:hover {
  1106. border-color: var(--agent-border-strong);
  1107. background: var(--bg-container);
  1108. }
  1109. .separator-chip--active {
  1110. border-color: rgba(20, 83, 45, 0.35);
  1111. background: rgba(20, 83, 45, 0.08);
  1112. color: var(--agent-accent);
  1113. }
  1114. .separator-chip__value {
  1115. font-size: 12px;
  1116. color: var(--agent-text-soft);
  1117. }
  1118. .separator-chip__close {
  1119. flex-shrink: 0;
  1120. font-size: 16px;
  1121. line-height: 1;
  1122. opacity: 0.6;
  1123. }
  1124. .drawer-footer {
  1125. display: flex;
  1126. justify-content: flex-end;
  1127. gap: 10px;
  1128. padding-top: 6px;
  1129. }
  1130. @media (max-width: 768px) {
  1131. .el-dialog__body {
  1132. padding: 16px;
  1133. }
  1134. :deep(.settings-tabs) {
  1135. display: block;
  1136. }
  1137. :deep(.settings-tabs > .el-tabs__header) {
  1138. margin-right: 0;
  1139. margin-bottom: 16px;
  1140. }
  1141. .form-grid,
  1142. .parser-rule-item,
  1143. .separator-grid {
  1144. grid-template-columns: 1fr;
  1145. }
  1146. }
  1147. }
  1148. </style>