index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. <template>
  2. <div class="knowledge-workbench fill-height">
  3. <header class="workbench-header">
  4. <div>
  5. <div class="d-flex align-center mb-2">
  6. <v-icon color="primary" class="mr-2">mdi-book-search-outline</v-icon>
  7. <span class="text-overline primary--text">DATAOPS KNOWLEDGE</span>
  8. </div>
  9. <h1 class="text-h4 font-weight-bold mb-2">数据治理知识库</h1>
  10. <p class="text-body-1 grey--text text--darken-1 mb-0">
  11. 基于当前有效治理事实进行检索与问答,每条结论都能核验来源版本和知识点。
  12. </p>
  13. </div>
  14. <div class="status-cluster">
  15. <v-chip small outlined color="success">
  16. <v-icon left small>mdi-database-check-outline</v-icon>
  17. 标准检索可用
  18. </v-chip>
  19. <v-chip v-if="capabilities.lightrag_shadow_only" small outlined color="primary">
  20. LightRAG 影子模式
  21. </v-chip>
  22. </div>
  23. </header>
  24. <main class="workbench-grid">
  25. <section class="query-panel">
  26. <div class="mode-switch mb-5">
  27. <v-btn-toggle v-model="action" mandatory dense color="primary">
  28. <v-btn value="ask">问答</v-btn>
  29. <v-btn value="search">仅检索</v-btn>
  30. </v-btn-toggle>
  31. <v-select
  32. v-model="mode"
  33. :items="modes"
  34. item-text="label"
  35. item-value="value"
  36. label="检索模式"
  37. dense
  38. outlined
  39. hide-details
  40. class="mode-select"
  41. />
  42. </div>
  43. <label class="text-subtitle-2 d-block mb-2" for="knowledge-query">治理问题</label>
  44. <v-textarea
  45. id="knowledge-query"
  46. v-model="query"
  47. outlined
  48. auto-grow
  49. rows="3"
  50. :placeholder="action === 'ask' ? '例如:客户同步流程由谁负责?它依赖哪些上游数据?' : '输入对象名、字段名、编码或治理概念'"
  51. :error-messages="queryError"
  52. @keydown.ctrl.enter="submit"
  53. />
  54. <div class="d-flex align-center justify-space-between">
  55. <span class="text-caption grey--text">Ctrl + Enter 提交;业务域范围由服务端授权决定</span>
  56. <v-btn color="primary" depressed :loading="loading" :disabled="!query.trim()" @click="submit">
  57. <v-icon left>mdi-arrow-right</v-icon>
  58. {{ action === 'ask' ? '生成回答' : '开始检索' }}
  59. </v-btn>
  60. </div>
  61. <v-alert v-if="error" type="error" outlined dense class="mt-5 mb-0">
  62. {{ error }}
  63. </v-alert>
  64. <v-alert v-if="degradedComponents.length" type="warning" outlined dense class="mt-5 mb-0">
  65. 部分能力暂时降级:{{ degradedComponents.join('、') }}。当前结果仅使用可用检索器生成。
  66. </v-alert>
  67. <div v-if="loading" class="result-skeleton mt-8" aria-label="正在检索">
  68. <v-skeleton-loader type="heading, paragraph, list-item-three-line, list-item-three-line" />
  69. </div>
  70. <div v-else-if="hasResult" class="result-area mt-8">
  71. <div v-if="action === 'ask'" class="answer-block">
  72. <div class="d-flex align-center justify-space-between mb-3">
  73. <h2 class="text-h6 mb-0">回答</h2>
  74. <v-chip small :color="freshnessColor" outlined>{{ freshnessLabel }}</v-chip>
  75. </div>
  76. <p v-if="answer" class="answer-copy">{{ answer }}</p>
  77. <v-alert v-else type="info" outlined dense class="mb-0">
  78. 当前有效证据不足,知识库没有生成推测性答案。你仍可查看下方检索来源。
  79. </v-alert>
  80. </div>
  81. <div class="evidence-header">
  82. <h2 class="text-h6 mb-0">{{ action === 'ask' ? '引用来源' : '检索结果' }}</h2>
  83. <span class="text-caption grey--text">{{ evidence.length }} 条</span>
  84. </div>
  85. <div v-if="evidence.length" class="evidence-list">
  86. <button
  87. v-for="(item, index) in evidence"
  88. :key="`${item.chunk_id}-${index}`"
  89. type="button"
  90. class="evidence-row"
  91. @click="openSource(item)"
  92. >
  93. <span class="evidence-index">{{ String(index + 1).padStart(2, '0') }}</span>
  94. <span class="evidence-main">
  95. <span class="d-flex align-center flex-wrap mb-2">
  96. <strong class="mr-2">{{ item.object_type }}</strong>
  97. <v-chip x-small outlined class="mr-2">v{{ item.object_version }}</v-chip>
  98. <v-chip x-small outlined color="primary">point r{{ firstRevision(item) }}</v-chip>
  99. </span>
  100. <span class="evidence-content">{{ item.content }}</span>
  101. <span class="evidence-meta mt-2">
  102. {{ firstPoint(item) }} · generation {{ item.index_generation }}
  103. </span>
  104. </span>
  105. <v-icon color="primary">mdi-open-in-new</v-icon>
  106. </button>
  107. </div>
  108. <div v-else class="empty-result">
  109. <v-icon size="40" color="grey lighten-1">mdi-file-search-outline</v-icon>
  110. <div class="text-subtitle-1 mt-3">没有找到可访问的有效来源</div>
  111. <div class="text-body-2 grey--text mt-1">尝试使用对象名、字段名或更具体的治理术语。</div>
  112. </div>
  113. </div>
  114. </section>
  115. <aside class="context-panel">
  116. <div class="context-section">
  117. <h2 class="text-subtitle-1 font-weight-bold mb-3">可信检索边界</h2>
  118. <div class="boundary-row">
  119. <v-icon small color="success">mdi-shield-check-outline</v-icon>
  120. <span>服务端业务域授权</span>
  121. </div>
  122. <div class="boundary-row">
  123. <v-icon small color="success">mdi-history</v-icon>
  124. <span>仅使用 active 版本</span>
  125. </div>
  126. <div class="boundary-row">
  127. <v-icon small color="success">mdi-link-variant</v-icon>
  128. <span>知识点级引用</span>
  129. </div>
  130. </div>
  131. <div v-if="isAdmin" class="context-section admin-section">
  132. <div class="d-flex align-center justify-space-between mb-3">
  133. <h2 class="text-subtitle-1 font-weight-bold mb-0">索引运行状态</h2>
  134. <v-btn icon small :loading="adminLoading" @click="loadAdmin">
  135. <v-icon small>mdi-refresh</v-icon>
  136. </v-btn>
  137. </div>
  138. <div class="metric-row">
  139. <span>有效文档</span><strong>{{ adminStatus.active_documents || 0 }}</strong>
  140. </div>
  141. <div class="metric-row">
  142. <span>待处理变更</span><strong>{{ statusCount('pending') }}</strong>
  143. </div>
  144. <div class="metric-row">
  145. <span>失败变更</span><strong :class="{ 'error--text': statusCount('failed') }">{{ statusCount('failed') }}</strong>
  146. </div>
  147. <v-divider class="my-4" />
  148. <div v-if="changeSets.length" class="change-list">
  149. <div v-for="item in changeSets.slice(0, 5)" :key="item.id" class="change-row">
  150. <div>
  151. <div class="text-body-2 font-weight-medium">{{ item.source_type }} · v{{ item.source_revision }}</div>
  152. <div class="text-caption grey--text">+{{ item.added_count }} / ~{{ item.modified_count }} / -{{ item.deleted_count }}</div>
  153. </div>
  154. <v-chip x-small outlined :color="changeColor(item.status)">{{ item.status }}</v-chip>
  155. </div>
  156. </div>
  157. <div v-else class="text-caption grey--text">暂无变更记录</div>
  158. </div>
  159. </aside>
  160. </main>
  161. <v-dialog v-model="sourceDialog" max-width="760">
  162. <v-card>
  163. <v-card-title class="d-flex justify-space-between">
  164. <span>治理来源核验</span>
  165. <v-btn icon @click="sourceDialog = false"><v-icon>mdi-close</v-icon></v-btn>
  166. </v-card-title>
  167. <v-divider />
  168. <v-card-text class="pt-5">
  169. <v-skeleton-loader v-if="sourceLoading" type="heading, paragraph" />
  170. <template v-else-if="sourceDetail">
  171. <div class="source-facts mb-4">
  172. <div><span>对象</span><strong>{{ sourceDetail.object_type }}</strong></div>
  173. <div><span>名称</span><strong>{{ sourceDetail.object_name }}</strong></div>
  174. <div><span>版本</span><strong>v{{ sourceDetail.object_version }}</strong></div>
  175. <div><span>Generation</span><strong>{{ sourceDetail.active_generation }}</strong></div>
  176. </div>
  177. <pre class="source-content">{{ sourceDetail.content }}</pre>
  178. </template>
  179. </v-card-text>
  180. </v-card>
  181. </v-dialog>
  182. </div>
  183. </template>
  184. <script>
  185. import {
  186. askKnowledge,
  187. getKnowledgeCapabilities,
  188. getKnowledgeChangeSets,
  189. getKnowledgeSource,
  190. getKnowledgeSync,
  191. searchKnowledge
  192. } from '@/api/governanceKnowledge'
  193. export default {
  194. name: 'governance-knowledge-base',
  195. data () {
  196. return {
  197. action: 'ask',
  198. mode: 'auto',
  199. query: '',
  200. queryError: '',
  201. loading: false,
  202. error: '',
  203. answer: null,
  204. answerStatus: '',
  205. evidence: [],
  206. freshness: 'fresh',
  207. degradedComponents: [],
  208. capabilities: {},
  209. adminStatus: {},
  210. changeSets: [],
  211. adminLoading: false,
  212. sourceDialog: false,
  213. sourceLoading: false,
  214. sourceDetail: null,
  215. modes: [
  216. { label: '自动判断', value: 'auto' },
  217. { label: '精确', value: 'exact' },
  218. { label: '语义', value: 'semantic' },
  219. { label: '关系', value: 'relationship' },
  220. { label: '全局', value: 'global' }
  221. ]
  222. }
  223. },
  224. computed: {
  225. isAdmin () {
  226. const permissions = this.$store.getters.userInfo?.permissions || []
  227. return permissions.includes('knowledge:manage')
  228. },
  229. hasResult () {
  230. return Boolean(this.answerStatus || this.evidence.length)
  231. },
  232. freshnessLabel () {
  233. return { fresh: '知识已同步', updating: '更新处理中', degraded: '状态降级' }[this.freshness] || this.freshness
  234. },
  235. freshnessColor () {
  236. return { fresh: 'success', updating: 'warning', degraded: 'error' }[this.freshness] || 'grey'
  237. }
  238. },
  239. created () {
  240. getKnowledgeCapabilities().then(res => { this.capabilities = res.data || {} }).catch(() => {})
  241. if (this.isAdmin) this.loadAdmin()
  242. },
  243. methods: {
  244. async submit () {
  245. if (!this.query.trim()) {
  246. this.queryError = '请输入治理问题'
  247. return
  248. }
  249. this.queryError = ''
  250. this.error = ''
  251. this.loading = true
  252. this.answer = null
  253. this.answerStatus = ''
  254. this.evidence = []
  255. this.degradedComponents = []
  256. try {
  257. const request = { query: this.query.trim(), mode: this.mode }
  258. const response = this.action === 'ask' ? await askKnowledge(request) : await searchKnowledge(request)
  259. const data = response.data || {}
  260. this.degradedComponents = data.degraded_components || []
  261. if (this.action === 'ask') {
  262. this.answer = data.answer
  263. this.answerStatus = data.answer_status
  264. this.evidence = data.citations || []
  265. this.freshness = data.freshness_status || 'degraded'
  266. } else {
  267. this.answerStatus = 'retrieved'
  268. this.evidence = data.evidence || []
  269. this.freshness = this.evidence.some(item => item.freshness_status === 'updating') ? 'updating' : 'fresh'
  270. }
  271. } catch (error) {
  272. this.error = typeof error === 'string' ? error : '知识检索暂时不可用,请稍后重试。'
  273. } finally {
  274. this.loading = false
  275. }
  276. },
  277. async loadAdmin () {
  278. this.adminLoading = true
  279. try {
  280. const [status, changes] = await Promise.all([getKnowledgeSync(), getKnowledgeChangeSets(20)])
  281. this.adminStatus = status.data || {}
  282. this.changeSets = changes.data || []
  283. } catch (error) {
  284. this.error = '管理员状态加载失败。'
  285. } finally {
  286. this.adminLoading = false
  287. }
  288. },
  289. async openSource (item) {
  290. this.sourceDialog = true
  291. this.sourceLoading = true
  292. this.sourceDetail = null
  293. try {
  294. const response = await getKnowledgeSource(item.object_uid)
  295. this.sourceDetail = response.data
  296. } catch (error) {
  297. this.sourceDialog = false
  298. this.error = '来源已更新、删除或当前无权访问。'
  299. } finally {
  300. this.sourceLoading = false
  301. }
  302. },
  303. firstPoint (item) {
  304. return item.point_key || (item.point_keys && item.point_keys[0]) || '未标注知识点'
  305. },
  306. firstRevision (item) {
  307. return item.point_revision || (item.point_revisions && item.point_revisions[0]) || '-'
  308. },
  309. statusCount (status) {
  310. return (this.adminStatus.change_sets && this.adminStatus.change_sets[status]) || 0
  311. },
  312. changeColor (status) {
  313. return { canonical_active: 'success', complete: 'success', failed: 'error', degraded: 'warning', pending: 'primary' }[status] || 'grey'
  314. }
  315. }
  316. }
  317. </script>
  318. <style lang="scss" scoped>
  319. .knowledge-workbench {
  320. min-height: 100%;
  321. background: #f5f7fa;
  322. color: #1f2937;
  323. }
  324. .workbench-header {
  325. display: flex;
  326. align-items: flex-start;
  327. justify-content: space-between;
  328. gap: 32px;
  329. padding: 36px 40px 28px;
  330. background: #fff;
  331. border-bottom: 1px solid #e5e9f0;
  332. }
  333. .status-cluster { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
  334. .workbench-grid {
  335. display: grid;
  336. grid-template-columns: minmax(0, 1fr) 320px;
  337. gap: 24px;
  338. max-width: 1440px;
  339. margin: 0 auto;
  340. padding: 28px 40px 48px;
  341. }
  342. .query-panel, .context-section {
  343. background: #fff;
  344. border: 1px solid #e3e8ef;
  345. border-radius: 12px;
  346. }
  347. .query-panel { padding: 28px; min-height: 620px; }
  348. .context-panel { display: flex; flex-direction: column; gap: 16px; }
  349. .context-section { padding: 20px; }
  350. .mode-switch { display: flex; justify-content: space-between; gap: 16px; }
  351. .mode-select { max-width: 180px; }
  352. .answer-block { padding: 20px; background: #f3f7fd; border-left: 3px solid #1976d2; border-radius: 8px; }
  353. .answer-copy { white-space: pre-wrap; line-height: 1.8; margin: 0; }
  354. .evidence-header { display: flex; justify-content: space-between; align-items: center; margin: 26px 0 10px; }
  355. .evidence-list { border-top: 1px solid #e5e9f0; }
  356. .evidence-row {
  357. width: 100%; display: grid; grid-template-columns: 38px minmax(0, 1fr) 24px; gap: 14px;
  358. align-items: start; padding: 18px 4px; border: 0; border-bottom: 1px solid #e5e9f0;
  359. background: transparent; color: inherit; text-align: left; cursor: pointer;
  360. }
  361. .evidence-row:hover { background: #f7f9fc; }
  362. .evidence-row:focus-visible { outline: 2px solid #1976d2; outline-offset: 2px; }
  363. .evidence-index { color: #78909c; font-size: 12px; font-weight: 700; padding-top: 3px; }
  364. .evidence-main { min-width: 0; display: flex; flex-direction: column; }
  365. .evidence-content { line-height: 1.65; overflow-wrap: anywhere; }
  366. .evidence-meta { color: #637083; font-size: 12px; overflow-wrap: anywhere; }
  367. .empty-result { text-align: center; padding: 56px 20px; border: 1px dashed #cfd8e3; border-radius: 10px; }
  368. .boundary-row, .metric-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 0; }
  369. .boundary-row { justify-content: flex-start; color: #475569; }
  370. .change-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid #edf0f4; }
  371. .source-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  372. .source-facts > div { display: flex; flex-direction: column; gap: 3px; }
  373. .source-facts span { color: #637083; font-size: 12px; }
  374. .source-content { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; background: #f5f7fa; border-radius: 8px; font: 13px/1.65 monospace; }
  375. @media (max-width: 960px) {
  376. .workbench-header { padding: 28px 24px; }
  377. .workbench-grid { grid-template-columns: 1fr; padding: 24px; }
  378. .context-panel { grid-row: 1; }
  379. }
  380. @media (max-width: 600px) {
  381. .workbench-header { flex-direction: column; gap: 16px; }
  382. .status-cluster { justify-content: flex-start; }
  383. .workbench-grid { padding: 16px; }
  384. .query-panel { padding: 20px 16px; }
  385. .mode-switch { flex-direction: column; }
  386. .mode-select { max-width: none; }
  387. .source-facts { grid-template-columns: 1fr; }
  388. }
  389. </style>