KnowledgeView.vue 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. <script setup lang="ts">
  2. import { onMounted, ref, watch } from 'vue'
  3. import type { KnowledgeRootDto, PathCheck } from '@agent-server/shared'
  4. import PageHeader from '../components/PageHeader.vue'
  5. import { admin, errorMessage, type KnowledgeInput } from '../lib/api'
  6. import { toast } from '../lib/ui'
  7. const roots = ref<KnowledgeRootDto[]>([])
  8. const editing = ref<(KnowledgeInput & { id?: string }) | null>(null)
  9. const check = ref<PathCheck | null>(null)
  10. const saving = ref(false)
  11. async function load() {
  12. roots.value = await admin.knowledge().catch(err => (toast(errorMessage(err), 'error'), []))
  13. }
  14. function edit(k?: KnowledgeRootDto) {
  15. editing.value = k
  16. ? { id: k.id, name: k.name, path: k.path, description: k.description, access: k.access, enabled: k.enabled }
  17. : { name: '', path: '/srv/agent-server/knowledge/', description: '', access: 'read', enabled: true }
  18. }
  19. let timer: ReturnType<typeof setTimeout> | undefined
  20. watch(() => editing.value?.path, p => {
  21. clearTimeout(timer)
  22. check.value = null
  23. if (!p?.trim()) return
  24. timer = setTimeout(async () => (check.value = await admin.checkPath(p).catch(() => null)), 300)
  25. })
  26. const valid = (c: PathCheck | null) => !!c && c.exists && c.readable && c.isDirectory && !c.denied
  27. async function save() {
  28. const e = editing.value
  29. if (!e) return
  30. saving.value = true
  31. try {
  32. const { id, ...body } = e
  33. if (id) await admin.updateKnowledge(id, body)
  34. else await admin.createKnowledge(body)
  35. editing.value = null
  36. toast('已保存', 'success')
  37. await load()
  38. } catch (err) {
  39. toast(errorMessage(err), 'error')
  40. } finally {
  41. saving.value = false
  42. }
  43. }
  44. async function remove(k: KnowledgeRootDto) {
  45. if (!confirm(`删除资料库「${k.name}」?(不会删除目录中的文件)`)) return
  46. await admin.deleteKnowledge(k.id).catch(err => toast(errorMessage(err), 'error'))
  47. await load()
  48. }
  49. onMounted(load)
  50. </script>
  51. <template>
  52. <div class="h-full flex flex-col">
  53. <PageHeader title="资料库" subtitle="登记服务器上的资料目录,在 Profile 中启用后 Agent 才能访问">
  54. <button type="button" class="btn-primary" @click="edit()"><span class="codicon codicon-add" />添加资料库</button>
  55. </PageHeader>
  56. <div class="flex-1 overflow-y-auto p-6">
  57. <div v-if="!roots.length" class="hint">还没有资料库。建议把资料放在 home 目录以外(例如 /srv/agent-server/knowledge),以 systemd 加固方式部署时无需额外配置。</div>
  58. <div class="flex flex-col gap-2 max-w-240">
  59. <div v-for="k in roots" :key="k.id" class="card p-3 flex items-start gap-3" :class="{ 'opacity-60': !k.enabled }">
  60. <span class="codicon codicon-library text-[20px] mt-0.5" :class="valid(k.check) ? 'text-accent' : 'text-error'" />
  61. <div class="flex-1 min-w-0">
  62. <div class="flex items-center gap-2">
  63. <span class="font-600">{{ k.name }}</span>
  64. <span class="badge bg-code">{{ k.access === 'write' ? '读写' : '只读' }}</span>
  65. <span v-if="!k.enabled" class="badge bg-code">已停用</span>
  66. </div>
  67. <div class="font-mono text-[12px] text-fg-muted break-all">{{ k.path }}</div>
  68. <div v-if="k.description" class="mt-1">{{ k.description }}</div>
  69. <div v-if="k.check.message" class="mt-1 text-[12px]" :class="valid(k.check) ? 'text-warning' : 'text-error'">{{ k.check.message }}</div>
  70. </div>
  71. <button type="button" class="icon-btn" title="编辑" @click="edit(k)"><span class="codicon codicon-edit" /></button>
  72. <button type="button" class="icon-btn" title="删除" @click="remove(k)"><span class="codicon codicon-trash" /></button>
  73. </div>
  74. </div>
  75. </div>
  76. <div v-if="editing" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editing = null">
  77. <form class="w-[520px] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl" @submit.prevent="save">
  78. <div class="font-600">{{ editing.id ? '编辑资料库' : '添加资料库' }}</div>
  79. <div><label class="label" for="k-name">名称</label><input id="k-name" v-model="editing.name" class="field" placeholder="例如:家庭账单"></div>
  80. <div>
  81. <label class="label" for="k-path">目录路径(服务器上的绝对路径,支持 ~)</label>
  82. <input id="k-path" v-model="editing.path" class="field font-mono">
  83. <div v-if="check" class="mt-1 text-[12px] flex items-center gap-1" :class="valid(check) ? (check.message ? 'text-warning' : 'text-success') : 'text-error'">
  84. <span class="codicon" :class="valid(check) ? 'codicon-pass' : 'codicon-error'" />
  85. {{ check.message ?? '路径可用' }}
  86. </div>
  87. </div>
  88. <div>
  89. <label class="label" for="k-desc">说明(会告诉 Agent 这里有什么资料)</label>
  90. <textarea id="k-desc" v-model="editing.description" class="field-area h-20 font-sans" placeholder="例如:2020 年以来的银行流水 CSV 和发票 PDF,按年份分目录" />
  91. </div>
  92. <div class="flex gap-4">
  93. <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.access" type="radio" value="read">只读</label>
  94. <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.access" type="radio" value="write">可读写</label>
  95. <span class="flex-1" />
  96. <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="editing.enabled" type="checkbox">启用</label>
  97. </div>
  98. <div class="flex justify-end gap-2 mt-2">
  99. <button type="button" class="btn-secondary" @click="editing = null">取消</button>
  100. <button type="submit" class="btn-primary" :disabled="saving || !editing.name.trim() || !valid(check)">保存</button>
  101. </div>
  102. </form>
  103. </div>
  104. </div>
  105. </template>